# App install tracking Source: https://docs.trygravity.com/advertisers/app-installs Track iOS and Android app installs and in-app events as Gravity conversions. Use this page to attribute app installs and in-app events to the Gravity ad that drove them. ## Choose an integration path > **[Adjust](#option-1-adjust)** > > Gravity is a dynamic network partner in Adjust. Gravity adds its callbacks to your Adjust tracker link. > **[Other MMP postback](#option-3-other-mmps-postback)** > > AppsFlyer, Kochava, Branch, or Singular. You set a postback URL in the MMP. You write no code. > **[Server-to-server](#option-2-server-to-server)** > > Your backend sends the install to the Gravity Conversions API. You control the data and the time. All three paths write to the same conversion pipeline. Gravity supports click-through and view-through attribution. An MMP (mobile measurement partner) is a service that measures app installs, for example Adjust or AppsFlyer. The [Tracking CLI](/advertisers/tracking-cli) can prepare the app setup. Run `gravity-tracking setup app --json` in your app project. Choose your backend or the MMP that you use. You configure the provider settings in the dashboard of that provider. *** ## How attribution works 1. A user clicks a Gravity ad. Gravity gives the click a unique click ID. 2. The user goes to your website or to the app store page. 3. The user installs and opens your app. 4. Your backend or your MMP sends the install to Gravity with the click ID. 5. Gravity attributes the install to the campaign and the ad. > [!NOTE] > > Click ID attribution does not need the IDFA. Obey your consent rules and the platform rules when you collect or share other identity signals. *** ## Option 1: Adjust Gravity is a **dynamic network partner** in Adjust. The Gravity callbacks are parameters on the tracker link. Adjust sends each callback when the install or the event occurs. * Adjust has no Data Sharing screen for Gravity. * Adjust does not ask you for a Gravity API key. * The callbacks do not contain a key. Gravity accepts a callback because its click ID is a click ID that Gravity issued. > [!NOTE] > > Send the in-app event that is the result of your campaign, and also send installs. Each in-app event needs its own callback on the link (see step 3). ### Before you start * Access to Campaign Lab in Adjust. * The Adjust **event token** for each in-app event that Gravity must receive. Find the tokens in Adjust, in **App → Events**. * A live Gravity campaign with an ad group. In step 4, you set the final link as its landing page. ### Set up the Adjust link **1. Create the tracker link in Adjust** In **Campaign Lab**, create a link for the Gravity network on your app. iOS and Android are different apps, so make one link for each. Copy the full click URL. It starts with `https://app.adjust.com/...` or `https://.go.link/...`. Do not use the App Store URL. **2. Send Gravity the link and your event tokens** Send the tracker links to your Gravity contact. Include the event token for each in-app event (for example trial, signup, or purchase). Gravity adds its callbacks and sends you the final link. To do this yourself, see [Build the link yourself](#build-the-link-yourself). **3. Examine the final link** The final link has one `install_callback=` parameter. It also has one `event_callback_=` parameter for each in-app event. On a `go.link` branded link, the names are `adj_install_callback=` and `adj_event_callback_=`. Each callback is a URL-encoded copy of the Gravity postback URL. It contains no API key. **4. Set the link as your ad group landing page** In Gravity, paste the final link as the **landing page of your ad group**. On each click, Gravity adds its click ID to the link as `click_id` (and `label`). Adjust stores the click ID and sends it back in the callbacks. Gravity then attributes the install and the events to the ad. If a callback has a click ID that Gravity does not know, the response is `401 Unknown click_id`. Gravity records nothing. ### Build the link yourself Add these parameters to your Adjust tracker link. Each callback value is the full Gravity URL, URL-encoded one time. This is the Gravity callback. It is the same for installs and for 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} ¤cy={currency} &app_id={app_id} &country={country} &campaign_name={campaign_name} ``` > [!WARNING] > > Do not put your Gravity API key in the callback. The tracker link is public, because it is in each ad click. Anyone can read it. This is a tracker link with the callbacks. Add one `event_callback_` for each event token: ``` https://app.adjust.com/abc123 ?install_callback= &event_callback_k9x2ab= &event_callback_p7m4qz= ``` A branded or universal link (`https://.go.link/...`) needs the `adj_` prefix on each parameter. Without the prefix, Adjust ignores the parameters: ``` https://yourapp.go.link/abc123 ?adj_install_callback= &adj_event_callback_k9x2ab= ``` Do not change the `{...}` placeholders. Adjust fills them when it sends the callback: * `{click_id}` is the Gravity click ID that Gravity adds on each click. * `{event_name}` tells Gravity which in-app event occurred. Without it, each in-app event arrives as a generic `event`. To see how Gravity names each Adjust event, see [MMP event names](#mmp-event-names). ### Verify the Adjust setup Install the app from a real Gravity ad click. Then make sure that the conversion shows in the Gravity dashboard as attributed. 1. Follow [the Adjust callback test steps](https://help.adjust.com/en/article/test-your-callbacks-partner). Add the advertising ID of your device to the final link. 2. Click the link through a Gravity ad, so that `click_id` is real. 3. Install and open the app. 4. Do the in-app events. > [!WARNING] > > The manual call below records a real conversion with no attribution. Use a test account. Do not put its API key on the public tracker 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" ``` The response is: ```json theme={null} { "status": "ok", "conversion_id": "uuid", "attributed": false, "event_type": "trial_started", "provider": "adjust" } ``` `attributed: false` is correct for a fake click ID. Without `api_key`, the same fake click ID returns `401 Unknown click_id`. ### Troubleshoot Adjust | Symptom | Cause | Fix | | - | - | - | | `401 Unknown click_id`, or all conversions have no attribution | The Gravity click ID does not get to the callback. | Set the ad group landing page to the Adjust link (`app.adjust.com` or `*.go.link`). Make sure that the callback has `click_id={click_id}`. Gravity keeps a click ID for 7 days after it serves the ad. After an install callback, Gravity keeps it for 90 days for later in-app events. A callback after that time gets `401`. | | Installs arrive, but no in-app events | The link has no `event_callback_`. | Add one `event_callback_=` for each in-app event. Adjust sends only the callbacks that are on the link. | | Nothing arrives from a `go.link` link | The callback parameters do not have the `adj_` prefix. | Use `adj_install_callback=` and `adj_event_callback_=` on branded and universal links. | | Each in-app event shows as `event` | The callback does not have `event_name={event_name}`. | Add it to the callback and build the link again. | | Purchases have attribution, but the revenue is `0` | The callback does not have `revenue={revenue}¤cy={currency}`. | Add them to the callback and build the link again. | | `"status": "duplicate"` | Gravity already received the same click ID and event type a short time before. | This is correct for a postback that the MMP sent again. Do nothing. | *** ## Option 2: Server-to-server Use this path when you control the app backend and can get the click ID from the ad click URL. > [!WARNING] > > Keep your Gravity API key in the `GRAVITY_API_KEY` environment variable on your backend. Do not put it in the app binary. A website-only pixel setup does not track native app events. ### Step 1: Capture the click ID on your landing page When a user arrives from a Gravity ad, the URL has a `grclid` parameter: ``` https://yourapp.com/download?grclid=abc-123 ``` Read the value and store it on your server: ```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 ID through your deep link Use your deep link or deferred deep link setup to carry `grclid` into the app. A plain App Store link does not keep website parameters. ### Step 3: Report the install 1. On the first open, create an install ID one time. 2. Store the install ID with the time of the first open. Use the same values on retries. 3. Send these values and the `grclid` (if you have it) from the app to your authenticated backend. 4. On the backend, validate and store the install. Then send it to Gravity. **Python** ```python theme={null} import requests import hashlib import os API_KEY = os.environ["GRAVITY_API_KEY"] GATEWAY_URL = "https://api.trygravity.com/gateway/events" def report_app_install(user, install): email_hash = hashlib.sha256( user["email"].strip().lower().encode() ).hexdigest() if user.get("email") else None payload = { "data": [{ "event_name": "AppInstall", "event_time": install["first_opened_at"], "event_id": f"install-{install['id']}", "action_source": "app", "user_data": { "grclid": install.get("grclid"), "em": [email_hash] if email_hash else None, "external_id": [str(user["id"])], }, "custom_data": { "value": 0, "currency": "USD", } }] } response = requests.post( GATEWAY_URL, json=payload, headers={"Authorization": f"Bearer {API_KEY}"}, timeout=10, ) response.raise_for_status() return response.json() ``` **Node.js** ```typescript theme={null} import crypto from 'crypto'; const API_KEY = process.env.GRAVITY_API_KEY; if (!API_KEY) throw new Error('Set GRAVITY_API_KEY on your backend'); const GATEWAY_URL = 'https://api.trygravity.com/gateway/events'; async function reportAppInstall( user: { id: string; email?: string }, install: { id: string; first_opened_at: number; grclid?: string }, ) { const hashPii = (val: string) => crypto.createHash('sha256') .update(val.trim().toLowerCase()) .digest('hex'); const payload = { data: [{ event_name: 'AppInstall', event_time: install.first_opened_at, event_id: `install-${install.id}`, action_source: 'app', user_data: { grclid: install.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), signal: AbortSignal.timeout(10000), }); if (!resp.ok) throw new Error(`Gravity returned ${resp.status}`); return resp.json(); } ``` **Swift** ```swift theme={null} import Foundation struct InstallReport: Encodable { let install_id: String let first_opened_at: Int let grclid: String? } func reportInstall( installID: String, firstOpenedAt: Int, grclid: String?, sessionToken: String ) async throws { let payload = InstallReport( install_id: installID, first_opened_at: firstOpenedAt, grclid: grclid ) var request = URLRequest( url: URL(string: "https://yourapp.com/api/app-installs")! ) request.httpMethod = "POST" request.timeoutInterval = 10 request.setValue("Bearer \(sessionToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(payload) let (_, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw URLError(.badServerResponse) } } ``` **Kotlin** ```kotlin theme={null} import java.net.HttpURLConnection import java.net.URL import org.json.JSONObject fun reportInstall( installId: String, firstOpenedAt: Long, grclid: String?, sessionToken: String, ) { val payload = JSONObject() .put("install_id", installId) .put("first_opened_at", firstOpenedAt) if (grclid != null) payload.put("grclid", grclid) val conn = URL("https://yourapp.com/api/app-installs") .openConnection() as HttpURLConnection try { conn.requestMethod = "POST" conn.connectTimeout = 10000 conn.readTimeout = 10000 conn.setRequestProperty("Authorization", "Bearer $sessionToken") conn.setRequestProperty("Content-Type", "application/json") conn.doOutput = true conn.outputStream.use { it.write(payload.toString().toByteArray(Charsets.UTF_8)) } check(conn.responseCode in 200..299) { "Install report rejected" } } finally { conn.disconnect() } } ``` The Swift and Kotlin examples send the install to **your backend**, with the session token of your app user. Your backend route must: 1. Authenticate the user. 2. Validate the consent and the stored install. 3. Call the Python or Node.js sender above. Run the Kotlin request off the UI thread. `first_opened_at` is in Unix seconds, not milliseconds. The response from Gravity is HTTP 200 with `"status": "accepted"` for the event. See [Response format](#response-format). ### Post-install events Send in-app purchases and other events after the install with the same request format: ```python theme={null} payload = { "data": [{ "event_name": "InAppPurchase", "event_time": purchase_created_at_unix, "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 send it | | - | - | - | | `AppInstall` | `app_install` | The first app open after the install. | | `AppOpen` | `app_open` | A later app open (re-engagement). | | `InAppPurchase` | `in_app_purchase` | An in-app purchase is complete. | | A custom string | The name in lowercase | A custom app event. | For all request fields, see [Server-side conversions](/advertisers/server-conversions). ### View-through attribution (no click) Some users see the ad but do not click it. They go to the app store directly. For these users, send the event without `grclid`. Include the hash of the user's email. Gravity can then try to match the install to an earlier ad view. ```python theme={null} payload = { "data": [{ "event_name": "AppInstall", "event_time": first_opened_at_unix, "event_id": f"install-{install_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 an MMP other than Adjust, set a postback URL in the dashboard of the MMP. For Adjust, the callbacks go on the tracker link. See [Option 1](#option-1-adjust). > [!WARNING] > > Keep API keys in the server postback settings of the MMP. Do not put them in your app binary or in public links. ### 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/generic` | The postback accepts `GET` with query parameters and `POST` with a JSON body. Authenticate with `?api_key=YOUR_GRAVITY_API_KEY` or an `Authorization: Bearer` header. Only the Adjust postback can authenticate with a Gravity click ID and no key. ### Set up the postback **1. Configure your click URL** In the Gravity partner settings of your MMP, make sure that the MMP stores the click ID from the Gravity ad URL as a custom parameter. Most MMPs name it `clickid`, `click_id`, or `label`. **2. Add the postback URL** In your MMP dashboard, add Gravity as a postback partner. Use the URL template for your MMP (see [Postback URL templates](#postback-url-templates)). **3. Test the integration** > [!WARNING] > > This test records a real conversion. MMP postbacks have no test mode. A fake click ID cannot verify ad attribution. Send a test postback: ```bash theme={null} curl --get "https://conversions.trygravity.ai/mmp/postback/appsflyer" \ -H "Authorization: Bearer $GRAVITY_API_KEY" \ --data-urlencode "clickid=test-click-123" \ --data-urlencode "event_name=install" \ --data-urlencode "event_time=$(date +%s)" \ --data-urlencode "ip=203.0.113.50" \ --data-urlencode "app_id=com.example.app" ``` The response has `"status": "ok"`. Then look for the event in **Events Manager**. ### Postback URL templates **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} ¤cy={event_revenue_currency} &app_id={app_id} &country_code={country_code} &campaign={campaign_name} &api_key=YOUR_GRAVITY_API_KEY ``` **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} ¤cy={currency} &app_id={app_id} &country_code={country_code} &campaign_name={campaign_name} &api_key=YOUR_GRAVITY_API_KEY ``` **Branch** ``` https://conversions.trygravity.ai/mmp/postback/branch ?click_id={click_id} &event_name={event_name} ×tamp={timestamp} &ip={ip} &revenue={revenue} &revenue_currency={revenue_currency} &api_key=YOUR_GRAVITY_API_KEY ``` **Singular** ``` https://conversions.trygravity.ai/mmp/postback/singular ?click_id={click_id} &event_name={event_name} ×tamp={timestamp} &idfa={idfa} &ip={ip} &revenue={revenue} ¤cy={currency} &campaign_name={campaign_name} &api_key=YOUR_GRAVITY_API_KEY ``` ### MMP event names The postback receiver changes common MMP event names to Gravity event types. This applies to Adjust and to the other MMPs: | MMP event name | Gravity event type | | - | - | | `install`, `attributed_install`, `first_open` | `app_install` | | `session`, `re_engagement`, `app_open`, `app_opened` | `app_open` | | `purchase`, `in_app_purchase`, `af_purchase`, `revenue` | `in_app_purchase` | | `registration`, `signup` | `complete_registration` | | `subscribe` | `subscribe` | | `add_to_cart` | `add_to_cart` | | `checkout` | `checkout_start` | | `lead` | `lead` | | A custom event | The name in lowercase, with spaces and hyphens changed to underscores | For Adjust, Gravity reads `event_name` first. If `event_name` is empty, Gravity uses `activity_kind` (for example `install`). A custom event such as `trial_started` keeps its name. It does not change to one of the types above. If you optimize for revenue, send `revenue` and `currency` with each purchase. *** ## Response format ### Server-to-server (CAPI) A request to `/gateway/events` returns an acknowledgement: ```json theme={null} { "events_received": 1, "events_processed": 1, "events_errored": 0, "results": [ { "event_id": "install-123", "status": "accepted", "attributed": false, "error": null } ], "test_mode": false } ``` `accepted` means that Gravity will process the event in the background. The response does not confirm storage or attribution. Look in **Events Manager** to make sure that the event arrived. To validate a payload and record no conversion, use [CAPI test mode](/advertisers/server-conversions#verify-the-integration). ### MMP postbacks An MMP postback returns: ```json theme={null} { "status": "ok", "conversion_id": "uuid", "attributed": true, "event_type": "app_install", "provider": "appsflyer" } ``` | Status | Description | | - | - | | `ok` | Gravity processed and stored the postback. | | `duplicate` | Gravity already received this postback. | | `error` | Gravity could not process the postback. Read the `error` field. The HTTP status is still `200`. | | HTTP status | Cause | | - | - | | `401` | The request has no API key, the API key is not valid, or (on Adjust with no key) Gravity does not know the click ID. | | `503` | Adjust with no key only: Gravity could not look up the click ID. Send the postback again later. | *** ## FAQ > **Do I need the IDFA for attribution?** > > No. Gravity attribution does not depend on the IDFA, so it works with the App Tracking Transparency rules of iOS 14.5 and later. If the user gave App Tracking Transparency consent and you have the IDFA, you can send it as an additional signal. > **What if the user does not click the ad?** > > Send the conversion without `grclid`. Include the hash of the user's email (`em`). Gravity can then try to match the install to an earlier ad view. > **Can I track events after the install?** > > Yes. Send `InAppPurchase` (or a custom event name) with `action_source: "app"`. Include `custom_data.value` for revenue attribution. Send the same `grclid` that you sent with the install. > **Which option do I use?** > > On **Adjust**, send Gravity your tracker link and event tokens. Then set the returned link as your ad group landing page. On another **MMP**, set the postback URL in the MMP dashboard. Use **server-to-server** if you want to send all the data from your backend. All three paths go into the same conversion pipeline. ## Next > **[Pixel and web conversions](/advertisers/pixel)** > > Track web conversions, for example purchases and signups. > **[Conversions API](/advertisers/server-conversions)** > > Send web conversions from your backend. # Campaigns Source: https://docs.trygravity.com/advertisers/campaigns Campaign objectives, ad groups, targeting, bids, and budgets. A campaign holds your objective, budget, and schedule. It has one or more **ad groups**, and each ad group holds your ads, the context Gravity matches to conversations, and the targeting. To create your first campaign, see the [Advertiser quickstart](/advertisers/quickstart). ## Campaign objective You pick the objective when you create the campaign. You can't change it after launch. | Objective | Gravity optimizes for | You pay | Who can use it | | - | - | - | - | | **Clicks** | Conversations most likely to produce a click | Per 1,000 impressions (CPM) | Everyone. No tracking needed. | | **Reach** | As many different people as possible, at the lowest CPM | Per 1,000 impressions (CPM) | Everyone. | | **Conversions** | A conversion event you pick, for example a purchase | Per 1,000 impressions (CPM) | Everyone. The event must be tracked with the [pixel](/advertisers/pixel) or [CAPI](/advertisers/server-conversions). | | **CPA** | A conversion event you pick | Per conversion that Gravity attributes to your ads | Accounts with \$5,000 of lifetime spend on Gravity, or accounts that Gravity has unlocked. Until then, the dashboard shows CPA as locked. | > [!NOTE] > > New campaigns go through a learning period, and the dashboard shows its progress. Performance moves around and costs run higher until it ends. Expect 2 to 3 months for Conversions campaigns and up to 30 days for other objectives. Pausing a campaign or making large budget changes can restart learning. ## Ad groups and creative Each ad group is one of two types: | Type | How it works | | - | - | | **Generative** | Gravity writes an ad for each conversation from your context. You supply the brand name, headline, call to action, product URL, context, and an optional image. | | **Static** | You write the ads, and Gravity shows them exactly as written. | ### Write good context Context tells Gravity what you sell and how people use it. Gravity uses it to match the ad group to conversations and to write generative ads. Write a few factual sentences: what the product is, its key features, and how someone uses it. Leave out slogans, keyword lists, and facts about other ad groups. ```text Good theme={null} Carbon-plated trail running shoe with a grippy outsole and a wide toe box. Built for long runs and races on rocky or muddy terrain. ``` ```text Weak theme={null} The best shoes in the world. Run faster. Shoes, sneakers, running, sport. ``` The weak example has no facts, so Gravity can't match it to a relevant conversation or write a specific ad from it. ## Targeting | Setting | Set on | What it does | | - | - | - | | **Context** | Ad group | Describes the conversations where your ad is relevant. | | **Locations** | Campaign, ad group | Countries where ads can serve. An ad group can narrow the campaign's list. None selected means all countries. | | **Devices** | Ad group | iPhone, Android, or desktop. None selected means all devices. | | **Audiences** | Ad group | Include or exclude the members of an audience. See [Audiences](#audiences). | | **Schedule** | Campaign | A start date and an optional end date. | ## Bids | Objective | How bids work | | - | - | | Clicks, Reach, Conversions | Gravity sets bids automatically to deliver your daily budget toward the objective. | | CPA | You set the CPA: the amount you pay for one conversion. | ## Budgets | Budget | Applies to | How it works | | - | - | - | | **Daily budget** | Clicks, Reach, Conversions | Gravity paces spend toward the daily budget. Spend on one day (UTC) can reach 110% of the daily budget. Then the campaign stops serving until the next day. | | **Monthly cap** | CPA | Serving stops when spend reaches the cap, each calendar month. Conversions from ads shown before then can still be billed. Gravity also spreads the cap across the month as a daily limit. | Budgets belong to the campaign, not to an ad group. ## Monitor a campaign Go to **Campaigns** and select a campaign to see impressions, clicks, CTR, and spend. When you track conversions, you also see results and the cost per result. To see the tracking events that Gravity receives, go to **Events Manager**. ## Audiences Build audiences on the **Audiences** page, then attach them to an ad group to include or exclude their members. An audience can group people who: * Saw or clicked your ads. * Visited your site. * Converted, or completed a specific event. * Are on a customer list that you upload (emails or phone numbers). Gravity hashes the values on upload and doesn't store the raw list. With no audience attached, an ad group serves to everyone. ## Next steps > **[Advertiser pixel](/advertisers/pixel)** > > Measure which ads produce conversions. # Dashboard API Source: https://docs.trygravity.com/advertisers/dashboard-api Read your advertiser reports and manage campaigns from code with your API key. The Dashboard API gives you the data and controls of the advertiser dashboard over REST. Use it to pull reports into your own tools, run scheduled jobs, or create and change campaigns from a script. This page walks through the common tasks. For every endpoint, parameter, response field, and error, see the [Advertiser Dashboard API reference](/api-reference/advertiser/overview). ## Base URL ``` https://platform.trygravity.com ``` ## Authentication Send your API key in the `X-API-Key` header on every request. You'll find the key in the [Gravity dashboard](https://app.trygravity.com) under **Settings → Organization**. ```bash theme={null} curl https://platform.trygravity.com/advertiser-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` > [!WARNING] > > The key has full access to your account, including spend. Keep it on your server. Your account has one key, shared by the Dashboard API, the Conversions API, MMP postbacks, and the dashboard MCP server. Regenerating it in **Settings → Organization** stops the old key everywhere at once. ## List your campaigns ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/campaigns?status=active" \ -H "X-API-Key: YOUR_API_KEY" ``` Each campaign comes back with its settings and lifetime impressions, clicks, spend, and conversions. Filter with `status` (`active`, `paused`, or `draft`), or leave it off to get every campaign. `delivery_status` tells you whether a campaign can serve right now. When delivery is limited or blocked, `delivery_issues` gives the reason, for example `ad_image_required`. ## Read a performance report `GET /advertiser-dashboard/metrics` returns one row per day and the totals for the range. ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/metrics?days=7&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` Use `days` (1 to 365) to look back from today, or `start_date` and `end_date` (`YYYY-MM-DD`) for a fixed range. Add `campaign_id` to report on one campaign. Set `tz` to the time zone your ad account reports in. It decides where each day starts and ends, so the rows match the Gravity dashboard. For ChatGPT and OpenAI campaigns, they also match the OpenAI reports, which currently use `America/New_York`. Pick one time zone and stick with it, because the same date in two time zones covers different hours. ```python Python theme={null} import os import requests API_KEY = os.environ["GRAVITY_API_KEY"] BASE = "https://platform.trygravity.com/advertiser-dashboard" metrics = requests.get( f"{BASE}/metrics", headers={"X-API-Key": API_KEY}, params={"days": 30}, timeout=30, ).json() print(f"Impressions: {metrics['total_impressions']:,}") print(f"Spend: ${metrics['total_spend']:.2f}") print(f"Conversions: {metrics['total_conversions']}") if metrics["total_spend"] > 0: print(f"ROAS: {metrics['total_revenue'] / metrics['total_spend']:.2f}x") ``` ```javascript JavaScript theme={null} const API_KEY = process.env.GRAVITY_API_KEY; const BASE = "https://platform.trygravity.com/advertiser-dashboard"; const res = await fetch(`${BASE}/metrics?days=30`, { headers: { "X-API-Key": API_KEY }, }); const metrics = await res.json(); console.log(`Impressions: ${metrics.total_impressions.toLocaleString()}`); console.log(`Spend: $${metrics.total_spend.toFixed(2)}`); console.log(`Conversions: ${metrics.total_conversions}`); ``` Other reports use the same date and time zone parameters: | To get | Call | | - | - | | Daily metrics for each ad group, for example for a weekly CSV | [`GET /advertiser-dashboard/ad-group-metrics`](/api-reference/advertiser/reports#get-/advertiser-dashboard/ad-group-metrics) | | Daily metrics for each ad unit (creative-level reports) | [`GET /advertiser-dashboard/ad-unit-metrics`](/api-reference/advertiser/reports#get-/advertiser-dashboard/ad-unit-metrics) | | Individual conversions, a page at a time | [`GET /advertiser-dashboard/conversions`](/api-reference/advertiser/reports#get-/advertiser-dashboard/conversions) | | Total spend, billed and unbilled amounts, and credits | [`GET /advertiser-dashboard/billing`](/api-reference/advertiser/reports#get-/advertiser-dashboard/billing) | | Pixel event counts by domain and event type | [`GET /advertiser-dashboard/events/analytics`](/api-reference/advertiser/reports#get-/advertiser-dashboard/events/analytics) | ## Create and launch a campaign Create the campaign as a draft first, so nothing spends while you check it. A campaign needs at least one ad group. A `generative` group writes its own ads from your `context`. A `manual` group serves the ads you write. ```bash theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/campaigns \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Summer Sale", "landing_page": "https://acme.com/summer", "daily_budget": 100, "status": "draft", "campaign_objective": "clicks", "allowed_countries": ["US", "CA"], "ad_groups": [ { "name": "Summer Sale (Generative)", "group_type": "generative", "brand_name": "Acme", "context": "Summer footwear sale, focus on running shoes" } ] }' ``` The response is `201` with the new campaign, including its `campaign_id` and ad groups. Users see `brand_name` next to the ad, so set it to the brand you advertise, not an internal account label. When the campaign looks right, launch it: ```bash theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456 \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "active"}' ``` Before Gravity activates the campaign, it checks that: * Your billing is ready. If not, you get `402`. * At least one ad group isn't paused. If all are paused, you get `409`. * A conversion campaign has received a CAPI event for its result event. If not, you get `409`. See [Server-side conversions](/advertisers/server-conversions). Activation can send the campaign to review again. ## Change a budget Send only the fields you want to change: ```bash theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456 \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"daily_budget": 150}' ``` `daily_budget` is an average in dollars. Spend on one day (UTC) can reach 110% of the daily budget. Then the campaign stops serving until the next day. A campaign with `"budget_type": "total"` uses `total_budget` instead, and a `target_cpa` campaign can also set a `monthly_budget`. A budget change can move the campaign to Gravity-only delivery. ## Pause, resume, or archive a campaign To pause, send `{"status": "paused"}` to `PATCH /advertiser-dashboard/campaigns/{campaign_id}`. Send `{"status": "active"}` to resume, with the same checks as a launch. To archive, call `PATCH /advertiser-dashboard/campaigns/{campaign_id}/archive`. Each call flips the archived state. Archiving also pauses the campaign, and a restored campaign stays paused until you set it to `active`. ## Target regions or customer lists To target regions or DMAs, look up their location IDs and send them in the campaign's `geo_targets` field: ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/geo-targets?q=california&target_type=region" \ -H "X-API-Key: YOUR_API_KEY" ``` To reach or exclude your own customers, upload a CSV of emails or phone numbers with `POST /advertiser-dashboard/audiences/custom-list`. Then send the audience IDs in `include_audience_ids` or `exclude_audience_ids`. Audiences work only on campaigns that deliver fully on a partner delivery network. See [Targeting](/api-reference/advertiser/targeting). ## Add a conversion pixel ```bash theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/pixels \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://acme.com", "name": "Acme main site"}' ``` Gravity checks the installation in the background. `is_functional` starts as `null` and changes to `true` or `false` once the check runs. To send conversions that your backend confirms, use the [Conversions API](/advertisers/server-conversions). ## Errors Errors come back as JSON with a `detail` field. The most common are `401` (the key isn't valid), `402` (billing blocks activation), `404` (the object isn't in your account), `409` (a conflict, for example all ad groups of the campaign are paused), and `422` (the request isn't valid, including a missing `X-API-Key` header). The [reference](/api-reference/advertiser/errors) lists every status and cause. ## MCP server An AI agent can use this API through the dashboard MCP server at `https://platform.trygravity.com/mcp`. The server uses the same API key, so an agent can read reports and also act in the dashboard, for example create and launch campaigns. The agent gets full admin access and actions run without confirmation. For setup, tools, and safety notes, see [Dashboard MCP server](/advertisers/dashboard-mcp). ## Questions Email [support@trygravity.com](mailto:support@trygravity.com) with any API question. # Dashboard MCP server for advertisers Source: https://docs.trygravity.com/advertisers/dashboard-mcp Connect an AI agent to your advertiser account. The agent can read reports and manage campaigns. The dashboard MCP server lets an AI agent work in your Gravity advertiser account. It can read campaign reports and do what you can do in the advertiser dashboard. > [!WARNING] > > Your API key gives an agent full admin access to your advertiser account, including the power to launch campaigns and spend money. Read [Access and safety](#access-and-safety) before you connect one. ## Access and safety An agent that connects with your API key acts as an admin of your advertiser account. It can: * Create and launch campaigns, change budgets, and spend money in a single call. The key has no spend limit. * Add and remove team members and invites. The server carries out each call right away. It does not ask you or the agent to confirm anything first. Your account has one API key, and every integration and agent shares it. When anyone regenerates the key, the old key stops working at once for all of them. To cut off one agent, regenerate the key and give the new key only to the integrations you want to keep. Don't paste the key into a shared chat or a shared file. ## Before you start * A Gravity advertiser account. * The API key of the advertiser account. Get it in the dashboard. * An MCP client that can send JSON-RPC requests over HTTP POST. See "Client support" below. ## Connect 1. Get the API key of your advertiser account in the dashboard. 2. Add `https://platform.trygravity.com/mcp` as an MCP server in your client. 3. Set the `X-API-Key` header to your API key. 4. Ask the client to list the tools. You see the `advertiser_*` tools, the `dashboard_*` tools for advertisers, and the account tools. ## Tools for advertisers **Reporting tools** return summaries. They do not change data: `advertiser_get_info`, `advertiser_list_campaigns`, `advertiser_get_campaign`, `advertiser_get_metrics`, `advertiser_get_ad_group_metrics`, `advertiser_get_conversions`, `advertiser_get_billing`, `advertiser_get_events_analytics`, `advertiser_get_conversion_stats`, and `advertiser_get_integrations`. **Dashboard tools** do the actions of the advertiser dashboard. Each tool name is `dashboard_` and the name of a dashboard action, for example `dashboard_create_campaign`. They cover campaigns, ad groups and ads, ad units, campaign metrics, audiences, billing, events, pixels, and conversion integrations (Shopify, Stripe, and Triple Whale). Call `tools/list` to get the full list for your key. **Account tools** cover the team, organizations, support, Slack, and the Gravity Index. ## Recipes Some advertiser tasks need more than one call. The server sends these recipes to the client in its `initialize` response. **Create and launch a campaign** 1. Call `dashboard_get_campaign_launch_limits`. 2. Call `dashboard_create_campaign` with the status `"draft"`. 3. Call `dashboard_create_ad_group` for each ad group. Put the ads in the same call. 4. Call `dashboard_launch_campaign`. > [!WARNING] > > `dashboard_launch_campaign` starts the campaign, and the campaign starts to spend money. Do a check of the campaign, its ad groups, and its budget before this call. **Edit a campaign** * To change the campaign settings, call `dashboard_update_campaign`. * To pause or resume a campaign, set its status with `dashboard_update_campaign`. * To list the ad groups of a campaign, call `dashboard_list_ad_groups` with `campaign_id`. * To change ad groups and ads, call `dashboard_update_ad_group`, `dashboard_create_ad`, `dashboard_update_ad`, or `dashboard_archive_ad`. **Copy a campaign, ad group, or ad** The server has no copy tool. Read the item with `dashboard_get_campaign`, `dashboard_get_ad_group`, or `dashboard_get_ad`. Then create a new item with `dashboard_create_campaign`, `dashboard_create_ad_group`, or `dashboard_create_ad`. **Compare with the previous period** Call the same metrics tool two times. Use the same length of time for each call. The second period ends when the first period starts. **List all ads in the account** Call `dashboard_list_ad_groups`. It returns the ads of each ad group. **Export to CSV** The tools return JSON, not CSV. The dashboard makes the CSV files for campaigns, ad groups, ads, and billing history in the browser. Make the CSV from the JSON. ## What the server cannot do for advertisers These actions are not available with an API key: * Change your password, two-factor authentication, or personal profile. * Create, list, switch, or close organizations. * Buy credits. These actions need a person in a browser: * **Add a card.** Use the Stripe form in the dashboard. * **Connect Stripe, Triple Whale, GitHub, or Vercel.** The tool returns a URL. A person must open the URL. * **Connect Shopify, or book a call.** A person must complete these steps in the dashboard. File uploads have no MCP tool. To upload an ad image, a campaign logo, a customer list, or an ad unit image, use the REST API under `https://platform.trygravity.com/dashboard`. ## Connection details | Item | Value | | - | - | | URL | `https://platform.trygravity.com/mcp` | | Other host | `https://platform.trygravity.ai/mcp` also works. | | Method | `POST` (JSON-RPC 2.0) | | Auth | `X-API-Key: YOUR_API_KEY`, or `Authorization: Bearer YOUR_API_KEY` | | Protocol version | `2024-11-05` | | Server name and version | `gravity-dashboard`, `2.0.0` | | Batch limit | 50 messages in one request | | Body limit | 256 KiB for each request | Advertiser accounts and publisher accounts use the same server. The API key tells the server which account you use, and the server shows only the tools for that account type. Every request needs the API key, also `initialize` and `tools/list`. A request without a valid key gets HTTP 401. To test the connection without a client, send a `tools/list` request: ```bash theme={null} curl https://platform.trygravity.com/mcp \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' ``` The response is a JSON-RPC result with a `tools` list. > [!NOTE] > > This page describes version `2.0.0` of the server. Tool names and tool groups can change in a later version. Use `tools/list` to get the current names. ## Results and errors * Each tool returns the JSON response of the dashboard action as text in `content`. * If the dashboard action fails, the result has `isError: true` and the text `HTTP : `. * A key can only get to its own account. A request for an account, organization, or publisher of a different key returns 404. One key cannot get to a different organization of the same owner. ## Client support The server uses JSON-RPC 2.0 over HTTP POST, with protocol version `2024-11-05`. It does not use the Streamable HTTP transport or Server-Sent Events. Some MCP clients need one of these transports and cannot connect. Gravity has not tested specific MCP clients with this server. Before you rely on a client, connect it and do a `tools/list` call. ## Troubleshoot | Problem | Cause | Fix | | - | - | - | | HTTP 401, "Not authenticated" | The request has no API key, or the key is not valid. | Send the key in `X-API-Key`. Make sure that nobody regenerated the key. | | HTTP 404 from a tool | The ID belongs to a different account or organization. | Use an ID from your own account. | | HTTP 413, "Request too large" or "Batch too large" | The body is larger than 256 KiB, or the batch has more than 50 messages. | Send smaller requests. | | The client cannot connect | The client needs Streamable HTTP or Server-Sent Events. | Use a client that sends JSON-RPC over HTTP POST. | | A tool is not in the list | The tool is for the other account type, or the tool name changed. | Use a key of the correct account type. Call `tools/list` again. | ## Related pages * [Dashboard MCP server for publishers](/ai-platforms/dashboard-mcp) uses the same server with a publisher key. * [Advertiser Dashboard API](/advertisers/dashboard-api) gives the same data over REST. * [Docs MCP server](/agents/docs-mcp) searches the docs. It cannot read or change your account. # Integrations Source: https://docs.trygravity.com/advertisers/integrations Track purchases and bookings with Shopify, Stripe, or Cal.com. Connect Shopify, Stripe, or Cal.com to send conversions to Gravity without your own backend code. If your conversions happen in one of these platforms, you do not need a [Conversions API](/advertisers/server-conversions) integration. > [!NOTE] > > An integration sends the conversions. It does not replace the [Gravity Pixel](/advertisers/pixel) on your website. The Pixel records the visit that led to the conversion. Shopify is the exception: the Shopify app installs its own Pixel on your storefront. ## Before you start * Access to the Gravity dashboard as a member of your advertiser account. * Admin access to your Shopify store, Stripe account, or Cal.com account. * For Stripe and Cal.com: the Gravity Pixel on your website, and your advertiser API key from **Settings → Organization**. > [!WARNING] > > The Stripe and Cal.com webhook URLs contain your API key. Enter them only in the settings of the provider. Do not share them or put them in source control. ## Shopify The Gravity Shopify app installs a Shopify web pixel on your storefront. It also subscribes to the `orders/paid` webhook. Gravity then gets purchase events from the checkout and from your Shopify server. **1. Open the integrations settings** In the [Gravity dashboard](https://app.trygravity.com), go to **Settings → Organization**. In the **Integrations** section, select **Connect** on the Shopify row. **2. Approve the app in Shopify** Follow the dialog to install the Gravity app in your Shopify store. Approve the permissions that the app requests. **3. Wait for verification** Gravity turns on the web pixel and starts to sync your product catalog. The Shopify row shows **Pending** until Gravity receives a valid event from the store. Then it shows **Connected**. Purchases arrive as `purchase` conversions, with the order value and currency. The checkout pixel and the `orders/paid` webhook use the Shopify order ID for deduplication. Gravity counts each order one time. > [!NOTE] > > You do not need the manual `gr-pix.js` snippet on your Shopify storefront. Add it to each page outside Shopify where visitors land first, for example your marketing site or blog. ## Stripe Connect Stripe in one of two ways. **Stripe Connect (one-click)** 1. Go to **Settings → Organization**. 2. In the **Integrations** section, select **Connect** on the Stripe row. The **Connect Stripe** dialog opens. 3. Select the Stripe option and authorize Gravity in Stripe. Gravity then manages the webhook for you. **Manual webhook** 1. Go to **Settings → Organization**. In the **Integrations** section, select **Connect** on the Stripe row. The **Connect Stripe** dialog shows your webhook URL. It has this format: ``` https://api.trygravity.com/stripe-conversions/webhook?api_key=YOUR_API_KEY ``` 2. In your Stripe dashboard, add a webhook endpoint with that URL. 3. Subscribe the endpoint to `checkout.session.completed` and `invoice.paid`. 4. Copy the **signing secret** of the endpoint from Stripe. 5. Paste the signing secret in the **Connect Stripe** dialog in Gravity. Gravity verifies the `Stripe-Signature` header of each event with that secret. Gravity rejects an event without a valid signature with HTTP `400`. | Stripe event | Recorded as | | - | - | | `checkout.session.completed` | `purchase` | | `invoice.paid` | `purchase`, for the first invoice of a subscription only | > [!WARNING] > > Gravity does not count subscription **renewals** as new conversions. Only the first `invoice.paid` of a subscription counts. Gravity also ignores `payment_intent.succeeded`, because it occurs together with the events above and would count the same payment two times. ### Pass attribution to Stripe When you create the Checkout Session, add the click fields from `getCAPIData()` to its `metadata`: `grclid`, `grcpid`, `grpuid`, `gradid`, `grsig`, and `graid`. As an alternative, put the `grclid` value in `client_reference_id`. Connect Stripe only if Stripe processes the conversions that you want to track. ## Cal.com Use Cal.com for demo bookings and consultations. Cal.com sends booking events directly to Gravity. **1. Add a webhook in Cal.com** In Cal.com, go to **Settings → Developer → Webhooks → New**. Use this subscriber URL: ``` https://api.trygravity.com/caldotcom/booking?api_key=YOUR_API_KEY ``` Find your advertiser API key in the Gravity dashboard under **Settings → Organization**. **2. Use the same key as the signing secret** Paste the **same API key** into the **Secret** field in Cal.com. Gravity verifies the `x-cal-signature-256` HMAC with it. If the verification fails, Gravity still records the booking, but marks it as unverified. **3. Select your triggers** Subscribe to the booking events that you want to track. Each trigger becomes a different conversion event: | Cal.com trigger | Conversion event | | - | - | | `BOOKING_CREATED` | `booking_created` | | `BOOKING_CONFIRMED` | `booking_confirmed` | | `BOOKING_RESCHEDULED` | `booking_rescheduled` | | `BOOKING_CANCELLED` | `booking_cancelled` | | `MEETING_ENDED` | `meeting_ended` | Gravity hashes the attendee email and phone (SHA-256) when it receives them. Gravity stores the hash, not the raw value. ### Pass attribution to Cal.com The Pixel gets the Gravity click IDs from the URL of your booking page. Pass them to Cal.com as booking `metadata.attribution`: `grclid`, `grcpid`, `grpuid`, `gradid`, `grsig`, and `graid`. Then Gravity can connect each booking to the ad that caused it. If only `grclid` arrives, Gravity finds the other IDs on its server. ## Check what is connected The [Dashboard API](/advertisers/dashboard-api) lists your connected Shopify stores: ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/integrations" \ -H "X-API-Key: YOUR_API_KEY" ``` The response is: ```json theme={null} { "shopify": [ { "id": "SHOPIFY_CONNECTION_ID", "shop_domain": "your-store.myshopify.com", "is_active": true, "connected_at": "2026-01-15T10:00:00+00:00", "verification_status": "verified" } ] } ``` This endpoint does not list Stripe or Cal.com. To check Stripe, go to **Settings → Organization** in the dashboard. To check Cal.com, look for booking events in **Events Manager**. ## Troubleshoot | Problem | Cause and fix | | - | - | | The Shopify row shows **Pending** | Gravity has not received a valid event from the store yet. Complete a test order, then check again. | | Stripe shows failed webhook deliveries with HTTP `400` | The signing secret in Gravity does not match the endpoint. Copy the signing secret again from Stripe. Paste it in the **Connect Stripe** dialog. | | Stripe renewals do not show as conversions | This is the expected result. Gravity counts only the first invoice of a subscription. | | Cal.com bookings show as unverified | The **Secret** in Cal.com is not your API key. Paste the same API key that is in the subscriber URL. | | Conversions arrive without attribution | The checkout or booking does not carry the Gravity click IDs. Pass the fields from `getCAPIData()` as described above. | ## Next steps > **[Conversions API](/advertisers/server-conversions)** > > Send conversions from your own backend. > **[Advertiser pixel](/advertisers/pixel)** > > Install the Pixel on your website. Shopify storefronts do not need it. # Advertiser pixel Source: https://docs.trygravity.com/advertisers/pixel Install the Gravity Pixel to connect website activity to the Gravity ad that caused it. The Gravity Pixel is a script that runs on your website. It records page views and the attribution context of each visit, for example the Gravity ad click that sent the visitor to you. The Pixel does not send conversions alone. Pair it with [server-side conversions (CAPI)](/advertisers/server-conversions) or a [supported integration](/advertisers/integrations). Then Gravity can connect confirmed purchases, signups, and demo requests to your ads. With both parts in place, your dashboard shows the conversion rate and ROAS for each campaign and ad. To set up the Pixel and CAPI with your coding agent, use the [Tracking CLI](/advertisers/tracking-cli). To install the Pixel by hand, follow the steps on this page. ## Before you start * Your Pixel ID. Find it in **Events Manager**. The Pixel ID is a UUID. It is not your account ID or your API key. * Access to the shared layout or header template of your website. * If your website is a Shopify store, [connect the Shopify app](/advertisers/integrations#shopify) instead. It installs a Pixel on your storefront. ## Install the pixel 1. Copy this snippet. Events Manager also shows a copy of it with your Pixel ID. ```html theme={null} ``` 2. Replace `YOUR_PIXEL_ID` with your Pixel ID. 3. Paste the snippet into the `` of the root layout or header template of your website. The Pixel must load on every page where visitors land or convert. 4. Install the snippet on each subdomain where visitors land or convert. A subdomain does not get the Pixel from the main domain. 5. Deploy your website. ## Verify the pixel 1. Open a page of your website in a browser. 2. Open the developer tools and select the **Network** tab. 3. Find a request to `api.trygravity.com/track/gr-events`. Make sure that the response is successful. 4. In **Events Manager**, find the **Browser pixel** row. It shows the most recent event that Gravity received. The script loads from `code.trygravity.com` and sends events to `api.trygravity.com`. If you use a [first-party proxy](#add-a-forwarding-rule), the requests go to your own domain. A successful Pixel request does not verify your server-side conversions. Verify them separately in Events Manager. ## Track conversions The Pixel records page views and attribution context. To record a conversion (a purchase or a signup, for example), do these steps: 1. In the browser, read the attribution data with `window.gravityPixel.getCAPIData()`. 2. Send that data to your backend with the business request, for example the order. 3. After your backend confirms the action, send the conversion to Gravity with CAPI. For the full procedure, see [Server-side conversions](/advertisers/server-conversions). ## Respect consent Connect the Pixel and your backend sender to your current consent rules: * Where the law requires consent, load the Pixel only after the visitor gives consent. * If the visitor withdraws consent, stop all future sends from the Pixel and your backend. The Pixel does not send events when the browser sends a Global Privacy Control (GPC) signal. ## Optional: Improve attribution with a first-party proxy A first-party proxy sends Pixel requests through **your own domain**. It can help when a browser or extension blocks requests to third-party domains. By default, the script loads from `code.trygravity.com` and sends events, sessions, and configuration requests to `api.trygravity.com`. Old script URLs on the `.ai` domain continue to work. ### Add a forwarding rule Select your platform. Each rule forwards `/gr/` on your domain to `https://api.trygravity.com/proxy/`. > **Vercel / Next.js** > > Add this to your `next.config.js` (or `next.config.ts`): > > ```javascript theme={null} > module.exports = { > async rewrites() { > return [ > { > source: "/gr/:path*", > destination: "https://api.trygravity.com/proxy/:path*", > }, > ]; > }, > }; > ``` > > Deploy your project. Vercel applies the rewrite at the edge. > **Cloudflare Worker** > > Make a Worker that receives `/gr/` requests: > > ```javascript theme={null} > export default { > async fetch(request) { > const url = new URL(request.url); > if (url.pathname.startsWith("/gr/")) { > const target = "https://api.trygravity.com/proxy/" > + url.pathname.slice(4) + url.search; > const headers = new Headers(request.headers); > headers.set("Host", "api.trygravity.com"); > headers.set("X-Forwarded-For", > request.headers.get("CF-Connecting-IP")); > return fetch(target, { > method: request.method, > headers, > body: request.body, > }); > } > return fetch(request); > }, > }; > ``` > > In the Cloudflare dashboard, attach the Worker to a route on your domain. > **Nginx** > > Add this to your server block: > > ```nginx theme={null} > location /gr/ { > proxy_pass https://api.trygravity.com/proxy/; > proxy_set_header Host api.trygravity.com; > proxy_set_header X-Forwarded-For $remote_addr; > proxy_set_header X-Forwarded-Proto $scheme; > proxy_ssl_server_name on; > } > ``` > **Caddy** > > ```caddyfile theme={null} > handle_path /gr/* { > rewrite * /proxy{uri} > reverse_proxy https://api.trygravity.com { > header_up Host {upstream_hostport} > header_up X-Forwarded-For {remote_host} > } > } > ``` > **Apache** > > Turn on `mod_proxy` and `mod_proxy_http`. Then add: > > ```apache theme={null} > ProxyPass "/gr/" "https://api.trygravity.com/proxy/" > ProxyPassReverse "/gr/" "https://api.trygravity.com/proxy/" > RequestHeader set Host "api.trygravity.com" > RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s" > ``` > **Express / Node.js** > > This example uses the `http-proxy-middleware` package: > > ```javascript theme={null} > const { createProxyMiddleware } = require("http-proxy-middleware"); > > app.use( > "/gr", > createProxyMiddleware({ > target: "https://api.trygravity.com/proxy", > changeOrigin: true, > }) > ); > ``` > [!TIP] > > You can use a different path in place of `/gr/`. Use the same path in the Pixel configuration below. ### Configure the pixel Add this script before the Pixel snippet: ```html theme={null} ``` Replace `/gr/` with the path of your forwarding rule. ### Verify the proxy 1. Open a page of your website in a browser. 2. Open the developer tools and select the **Network** tab. 3. Find requests to **your domain** at `/gr/init`, `/gr/events`, and `/gr/session`. Make sure that the responses are successful. 4. In **Events Manager**, find a recent event in the **Browser pixel** row. ## Troubleshoot | Problem | Cause and fix | | - | - | | No request to `/track/gr-events` | The snippet does not load on the page. Put it in the shared layout. Make sure that a consent tool or ad blocker does not stop it. | | The request is successful, but Events Manager shows no event | The Pixel ID is wrong. Copy the Pixel ID again from Events Manager. | | No events from one subdomain | That subdomain does not have the snippet. Install it there. | | Proxy requests return `404` | The forwarding rule does not match the path in `proxyEndpoint`. Use the same path in both places. | | No events in a browser with GPC turned on | This is the expected result. The Pixel does not send events when the browser sends a GPC signal. | ## Next steps > **[Conversions API](/advertisers/server-conversions)** > > Send confirmed conversions from your backend. > **[App installs](/advertisers/app-installs)** > > Track iOS and Android installs as conversions. # Advertiser quickstart Source: https://docs.trygravity.com/advertisers/quickstart Add a payment method, set up conversion tracking, and launch your first campaign. Three steps, in this order: add a payment method, set up tracking, then create the campaign. Tracking comes first so your first conversions count, and because Conversions campaigns need it to launch. > [!TIP] > > Working with a coding agent such as Claude Code, Cursor, or Codex? Step 2 has a [ready-made prompt](#option-a-set-up-with-an-ai-agent) that sets up tracking in your project. ## What you set up For a website, tracking has two parts: 1. **The Gravity Pixel.** A script on your site that records visits and the ad click that brought each visitor. 2. **Conversions.** A conversion is a confirmed customer action, such as a purchase or a signup. Your backend sends conversions with the Conversions API (CAPI), or a [Shopify, Stripe, or Cal.com integration](/advertisers/integrations) sends them for you. Gravity joins each conversion to the ad that led to it. For the terms, see [Core concepts](/introduction/concepts). ## Before you start * A Gravity advertiser account. [Sign up here](https://app.trygravity.com/advertiser/signup) if you don't have one. * Access to the source code of your website and its backend. * For the agent path: a coding agent and Node.js 20 or later. * For the manual path: your Pixel ID from **Events Manager** and your advertiser API key from **Settings → Organization**. > [!WARNING] > > Your API key is a secret. Keep it on your backend, and never put it in browser code, mobile apps, prompts, or source control. ## Step 1: Add a payment method 1. Sign in to the [Gravity dashboard](https://app.trygravity.com). 2. Go to **Settings → Billing**. 3. Add a payment method. You can build campaigns without one, but the dashboard saves them as drafts until a payment method is on file. ## Step 2: Set up tracking Pick one path. Both end in the same place: the Pixel on every page, and confirmed conversions sent from your backend. On Shopify, [connect the Gravity Shopify app](/advertisers/integrations#shopify) instead. It installs the Pixel on your storefront and sends purchases for you. For a mobile app, see [App installs](/advertisers/app-installs). ### Option A: Set up with an AI agent The [Tracking CLI](/advertisers/tracking-cli) (`@gravity-ai/tracking`) inspects your project and gives your coding agent a setup plan. The agent makes the code changes, and you review and deploy them. Prompt: set up Gravity tracking Copy it, then paste it into your coding agent. 1. Open your website's source project in your coding agent. 2. Copy the prompt above and paste it into the agent. It asks for your advertiser account name first. 3. When your browser opens, sign in to Gravity and select **Authorize CLI**. 4. Read the changes and the list of missing inputs that the agent shows you. 5. Add your API key to the environment of your backend host as `GRAVITY_API_KEY`. 6. Deploy your changes. The event list is a list of candidates. The agent wires up only the actions your project actually has. The CLI never deploys code, sets secrets, or sends events on its own. ### Option B: Set up by hand 1. Copy this snippet into the `` of your website. Put it in the root layout or header template, so that it loads on every page. ```html theme={null} ``` 2. Replace `YOUR_PIXEL_ID` with the Pixel ID from **Events Manager**. Events Manager also shows a snippet that you can copy. 3. Add the snippet to each subdomain where visitors land or convert. 4. In the browser code that submits an order, read the attribution data from the Pixel. Send it to your backend with the order: ```javascript theme={null} var capiData = window.gravityPixel?.getCAPIData?.() || {}; fetch('/api/complete-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_id: 'order-123', total: 99.99, email: 'customer@example.com', gravity: capiData }) }); ``` 5. After your backend confirms the order, send the conversion to Gravity. Use the order data from your backend, not values from the browser. ```python theme={null} import os import requests gravity_data = request.json.get("gravity", {}) requests.post( "https://api.trygravity.com/gateway/events", headers={"Authorization": f"Bearer {os.environ['GRAVITY_API_KEY']}"}, json={ "data": [{ "event_name": "Purchase", "event_time": order_created_at_unix, "event_id": f"order-{order_id}", "action_source": "website", "event_source_url": gravity_data.get("event_source_url", ""), "user_data": { **gravity_data.get("user_data", {}), "em": [customer_email], }, "client_context": gravity_data.get("client_context"), "custom_data": { "value": order_total, "currency": "USD", "order_id": order_id, }, }], }, timeout=10, ).raise_for_status() ``` 6. If you send the same event again (for example, on a retry), use the same `event_id` and `event_time`. 7. Deploy your changes. For all fields and more examples, see [Server-side conversions](/advertisers/server-conversions). ## Step 3: Verify tracking 1. Open your website in a browser. 2. Open the developer tools and select the **Network** tab. 3. Find a request to `api.trygravity.com/track/gr-events`. Make sure that the response is successful. 4. In **Events Manager**, find the **Browser pixel** row. It shows a recent event. 5. Complete one real conversion on your website, for example an order. 6. In **Events Manager**, find the conversion event from your backend. A successful network request only proves the Pixel loads. The conversion event in Events Manager is what proves your backend is sending conversions. ## Step 4: Create and launch a campaign 1. In the dashboard, go to **Campaigns**. 2. Select **Create**, then **Create campaign**. The campaign builder opens. 3. Fill in the campaign details: | Field | What to enter | | - | - | | **Name** | An internal label. Users never see it. | | **Objective** | Clicks, Reach, or Conversions. See [Campaigns](/advertisers/campaigns#campaign-objective). | | **Daily budget** | Your average daily spend target. | 4. Add at least one ad group. An ad group holds your ads and the context Gravity uses to match them to conversations. 5. Select **Launch campaign**. Gravity reviews every new campaign before delivery starts. A Conversions campaign also needs a tracked conversion event, which you set up in Step 2 and confirmed in Step 3. ## Step 5: Monitor the campaign Go to **Campaigns** and select your campaign to see impressions, clicks, and spend. Once conversions come in, you also see results and the cost per result. ## Troubleshoot | Problem | Cause and fix | | - | - | | No request to `/track/gr-events` | The snippet is not on the page. Make sure that the root layout loads it, and that a consent tool or ad blocker does not stop it. | | The request goes to your own domain | You use a [first-party proxy](/advertisers/pixel#add-a-forwarding-rule). Look for `/events` on your proxy path. | | No **Browser pixel** event in Events Manager | The Pixel ID is wrong. Use the Pixel ID from Events Manager, not your account ID or API key. | | CAPI returns `401` | The API key is missing or not correct. Copy the key again from **Settings → Organization**. | | Conversions arrive without attribution | The backend does not pass the `gravity` data from `getCAPIData()`. Send all of it with the event. | | The campaign stays a draft | Your account has no payment method. Add one in **Settings → Billing**, then launch the draft. | ## Next steps * [Conversions API](/advertisers/server-conversions): every CAPI field, with Python, Node.js, and cURL examples. * [Campaigns](/advertisers/campaigns): objectives, ad groups, targeting, bids, and budgets. Questions? Write to [support@trygravity.com](mailto:support@trygravity.com). # Server-side conversions (CAPI) Source: https://docs.trygravity.com/advertisers/server-conversions Send conversion events from your backend to the Gravity Conversions API. Use the Gravity Conversions API (CAPI) to send conversions from your backend to Gravity. > [!WARNING] > > **A conversion campaign needs CAPI.** Gravity does not activate a conversion campaign until it receives a CAPI event for the campaign's result event. Use the pixel for browser activity and attribution context. Use CAPI for purchases, signups, leads, and other events that your backend confirms. The [Tracking CLI](/advertisers/tracking-cli) can help your AI agent prepare the CAPI setup in your project. This page gives the manual integration contract. ## Why send conversions from the server The Gravity pixel records page views and detects some conversions in the browser. A server event is more reliable for events that your backend confirms: * **Delivery:** the event does not depend on the browser. * **Data:** you can add customer email, phone, order details, and line items that the frontend does not have. * **Deduplication:** an `event_id` makes sure that Gravity counts the same conversion only once. * **Offline conversions:** you can send phone calls, in-store purchases, and CRM events. ## How it works ```mermaid theme={null} sequenceDiagram participant Pixel as Gravity Pixel participant Browser as User's browser participant Backend as Your backend participant CAPI as Gravity CAPI Pixel->>Browser: Stores attribution context Browser->>Backend: Order + getCAPIData() result Backend->>CAPI: POST /gateway/events CAPI-->>Backend: Accepted, processed later ``` 1. The Gravity pixel runs on your site and records the attribution context. 2. At the conversion, your frontend reads `window.gravityPixel.getCAPIData()`. It sends the result to your backend with the order. 3. Your backend confirms the action. Then it calls `POST /gateway/events` with the pixel data and the conversion details. ## Before you start * A Gravity advertiser account. * Your Gravity API key. Find it in the Gravity dashboard in **Settings → Organization**. The API key is not the same as your pixel ID. * The Gravity pixel on your site, if you want browser attribution. See [Pixel setup](/advertisers/pixel). * A backend that can keep the API key secret. > [!WARNING] > > Keep the API key on your backend. Do not put it in browser code, in a mobile app, or in a public URL. ## Step 1: Install the pixel Add the pixel to every page, if it is not there yet: ```html theme={null} ``` ## Step 2: Read the pixel data at the conversion When the user completes a conversion, call `getCAPIData()`. Send the result to your backend with the order: ```javascript theme={null} // Include attribution when submitting the order to your backend var capiData = window.gravityPixel?.getCAPIData?.() || {}; fetch('/api/complete-order', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_id: 'order-123', total: 99.99, currency: 'USD', email: 'customer@example.com', phone: '+15551234567', // include this if you collected a phone number gravity: capiData }) }); ``` > [!NOTE] > > You can call `getCAPIData()` at any time after the pixel loads. Before the user gives consent, it returns `null`, so keep the `|| {}` fallback. If the user did not arrive from a Gravity ad, the attribution fields are `null`. Gravity still records the conversion, but it does not attribute it to an ad. ## Step 3: Send the conversion from your backend Add your order details to the pixel data. Then send a `POST` request to the Gravity gateway. > [!NOTE] > > You can send email and phone in plaintext. The API normalizes and hashes them. If you hash an email yourself, remove the spaces at the start and end, and change it to lowercase. Then hash its UTF-8 bytes with SHA-256. ```python Python theme={null} import os import requests API_KEY = os.environ["GRAVITY_API_KEY"] def send_gravity_conversion(order, gravity_data): user_data = gravity_data.get("user_data", {}) response = requests.post( "https://api.trygravity.com/gateway/events", headers={"Authorization": f"Bearer {API_KEY}"}, json={ "data": [{ "event_name": "Purchase", "event_time": order["created_at_unix"], "event_id": f"order-{order['id']}", "action_source": "website", "event_source_url": gravity_data.get("event_source_url", ""), "user_data": { **user_data, "em": [order["email"]], **({"ph": [order["phone"]]} if order.get("phone") else {}), "external_id": [str(order["customer_id"])], }, "client_context": gravity_data.get("client_context"), "custom_data": { "value": order["total"], "currency": order.get("currency", "USD"), "order_id": str(order["id"]), "contents": [ {"id": item["sku"], "quantity": item["qty"], "item_price": item["price"]} for item in order.get("items", []) ], }, }], }, timeout=10, ) response.raise_for_status() return response.json() ``` ```javascript Node.js theme={null} const API_KEY = process.env.GRAVITY_API_KEY; if (!API_KEY) throw new Error('Set GRAVITY_API_KEY on your backend'); async function sendGravityConversion(order, gravityData) { const { user_data: userData = {}, event_source_url = '', client_context = null, } = gravityData; const response = await fetch( 'https://api.trygravity.com/gateway/events', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ data: [{ event_name: 'Purchase', event_time: order.createdAtUnix, event_id: `order-${order.id}`, action_source: 'website', event_source_url, user_data: { ...userData, em: [order.email], ...(order.phone ? { ph: [order.phone] } : {}), external_id: [String(order.customerId)], }, client_context, custom_data: { value: order.total, currency: order.currency || 'USD', order_id: String(order.id), contents: (order.items || []).map(item => ({ id: item.sku, quantity: item.qty, item_price: item.price, })), }, }], }), signal: AbortSignal.timeout(10000), } ); if (!response.ok) throw new Error(`Gravity returned ${response.status}`); return response.json(); } ``` ```bash cURL theme={null} curl -X POST "https://api.trygravity.com/gateway/events" \ -H "Authorization: Bearer $GRAVITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "test_event_code": "TEST123", "data": [{ "event_name": "Purchase", "event_time": '"$(date +%s)"', "event_id": "order-test-001", "action_source": "website", "event_source_url": "https://yoursite.com/thank-you", "user_data": { "em": ["customer@example.com"], "ph": ["+15551234567"], "grclid": "grclid-example-123", "external_id": ["customer-456"] }, "custom_data": { "value": 99.99, "currency": "USD", "order_id": "order-789" } }] }' ``` The cURL example has `"test_event_code"`, so Gravity validates the event but does not record a conversion. Remove `test_event_code` when you go live. The response is HTTP 200 with a `status` for each event. See [Response](#response). ## Request reference `POST https://api.trygravity.com/gateway/events` ### Authentication Send your API key in one of these two ways: ``` Authorization: Bearer YOUR_API_KEY ``` ``` ?api_key=YOUR_API_KEY ``` Use the header when you can. Servers and proxies often write query strings to their logs. If the request has no key, the response is `401` with `API key required`. If the key is not valid, the response is `401` with `Invalid API key`. ### Request body | Field | Type | Required | Description | | - | - | - | - | | `data` | `object[]` | Yes | The events. Send 1 to 1,000 events in one request. | | `test_event_code` | `string` | No | Test mode. Gravity validates the events but does not store them. | ### Event fields | Field | Type | Required | Description | | - | - | - | - | | `event_name` | `string` | Yes | A standard event name (see [Event types](#event-types)) or a custom name. | | `event_time` | `integer` or `string` | Yes | The time of the action. Send Unix seconds, Unix milliseconds, or ISO 8601. | | `action_source` | `string` | Yes | The origin of the event: `website`, `app`, `email`, `phone_call`, `chat`, `physical_store`, `system_generated`, `pixel`, or `other`. | | `user_data` | `object` | Yes | Customer data and attribution IDs. See [User data fields](#user-data-fields). Send `{}` if you have no data. | | `event_id` | `string` | No | Your unique ID for the action, at most 128 characters. Gravity uses it for deduplication. | | `event_source_url` | `string` | No | The URL of the page where the event occurred. | | `custom_data` | `object` | No | The value, currency, and order details. See [Custom data](#custom-data). | | `client_context` | `object` | No | Device context from `getCAPIData()`. | | `opt_out` | `boolean` | No | Set to `true` if the user opted out. Gravity does not store the event. | ## Event types | Event name | When to send it | | - | - | | `Purchase` | An order is complete. | | `Lead` | A user submits a form or asks for a demo. | | `CompleteRegistration` | A user creates an account. | | `Subscribe` | A subscription starts. | | `StartTrial` | A free trial starts. | | `InitiateCheckout` | A checkout starts. | | `AddToCart` | A user adds an item to the cart. | | `Contact` | A user sends a contact form or calls you. | | `Schedule` | A user books an appointment. | Gravity also knows these standard names: `AddPaymentInfo`, `AddToWishlist`, `SubmitApplication`, `ViewContent`, `Search`, `CustomizeProduct`, `FindLocation`, `Donate`, `PageView`, `AppInstall`, `AppOpen`, and `InAppPurchase`. You can also send a custom event name, for example `"event_name": "DownloadWhitepaper"`. ## User data fields ### Attribution fields (from the pixel) `getCAPIData()` adds these fields to `user_data`: | Field | Description | | - | - | | `grclid` | The Gravity click ID. | | `grcpid` | The Gravity campaign ID. | | `grpuid` | The Gravity publisher ID. | | `gradid` | The Gravity ad ID. | | `grsig` | The Gravity attribution (UTM) signature. | | `graid` | The Gravity attribution ID. Gravity uses it to find the other IDs on the server. | | `visitor_id` | The visitor ID. | | `session_id` | The current session ID. | | `client_user_agent` | The browser user agent string. | Send the `user_data` object from `getCAPIData()` without changes, when possible. ### Device context (from the pixel) `getCAPIData()` also returns a `client_context` object. It contains device and browser signals, for example: | Field | Description | | - | - | | `timezone` | The user's time zone, for example `America/New_York`. | | `screen` | The screen dimensions and color depth. | | `viewport` | The browser viewport dimensions. | | `platform` | The OS platform, for example `macOS` or `Windows`. | | `connection` | The network connection type. | Send the full `client_context` object from your frontend to your backend. Then add it to the CAPI request without changes. Gravity uses these signals for attribution and bot detection. ### Customer identity (from your backend) Add these fields on your backend to get more matches: | Field | Type | Description | | - | - | - | | `em` | `string[]` | Email addresses. Plaintext is permitted. Gravity hashes it before storage. | | `ph` | `string[]` | Phone numbers. Send this when you collect one. Plaintext is permitted. Gravity keeps only the digits and then hashes them, so `+1 (555) 123-4567`, `15551234567`, and E.164 give the same hash. | | `fn` | `string` | First name. | | `ln` | `string` | Last name. | | `external_id` | `string[]` | The customer or user IDs from your system. | | `client_ip_address` | `string` | The user's IP address. | > [!NOTE] > > You can send `em`, `ph`, `fn`, and `ln` as plaintext. Gravity normalizes them and hashes them with SHA-256 for storage. A hashed value must be a 64-character hex string. Before you hash, trim and lowercase emails, and keep only the digits of phone numbers. > [!TIP] > > Send a phone number (`ph`) when you collect it and your consent rules permit it. Gravity can use the hash as an additional identity signal. ## Custom data | Field | Type | Description | | - | - | - | | `value` | `number` | The conversion value, for example the order total. It cannot be negative. | | `currency` | `string` | A three-letter ISO 4217 currency code. If you do not send it, Gravity uses `USD`. | | `order_id` | `string` | Your order or transaction ID. | | `contents` | `object[]` | Line items: `{ id, quantity, item_price, title }`. | | `content_name` | `string` | The product or content name. | | `content_category` | `string` | The product category. | | `predicted_ltv` | `number` | The predicted customer lifetime value. | ## Response A normal request returns HTTP 200: ```json theme={null} { "events_received": 1, "events_processed": 1, "events_errored": 0, "results": [ { "event_id": "order-123", "status": "accepted", "attributed": false, "error": null } ], "test_mode": false } ``` A normal request returns `accepted` for each event. Gravity stores and attributes the events in the background. In this response, `attributed` is always `false`, because attribution occurs later. Test mode processes the events at once and returns the other statuses. | Status | Description | | - | - | | `accepted` | Gravity accepted the event and will process it in the background. This does not prove storage or attribution. | | `ok` | Gravity processed and stored the event. | | `duplicate` | Gravity already received this `event_id` and `event_name` in the last 48 hours. | | `test_ok` | The event is valid. Gravity did not store it, because the request is in test mode. | | `skipped` | The event has `opt_out: true`. Gravity did not store it. | | `error` | Gravity could not process the event. Read the `error` field. | ### Errors | HTTP status | Cause | | - | - | | `401` | The request has no API key, or the key is not valid. | | `422` | The request body is not valid. For example, a required field is not there, `data` has more than 1,000 events, or `currency` does not have three letters. Gravity rejects the full batch. | | `429` | Too many requests. Wait for the number of seconds in the `Retry-After` header, then send again. | ## Deduplication Gravity deduplicates events by `event_id` and `event_name` in a 48-hour window. Gravity does not deduplicate an event that has no `event_id`. * Use one stable ID for each confirmed action. Your order ID is a good choice. * When you retry, send the same `event_id`, `event_name`, and `event_time`. * If the pixel and your server both send the same conversion, use the same event name and ID in both. ## Batch events You can send up to 1,000 events in one request. Add more objects to the `data` array. The normal response only confirms that Gravity accepted the events. Look in **Events Manager** to make sure that the events arrived. ## Verify the integration **1. Send a test event** Send a request with `"test_event_code": "TEST123"`. A valid event returns `"status": "test_ok"`. Gravity does not store a conversion in test mode. **2. Send a real event** Remove `test_event_code`. Send a confirmed conversion from your backend. The response is HTTP 200 with `"status": "accepted"`. **3. Look at the dashboard** Open **Events Manager**. Find a recent event on the **Conversions API** row. Then look for the conversion in your reports. An event can arrive and not match a Gravity ad. ## Troubleshoot | Symptom | Cause | Fix | | - | - | - | | `401 Unauthorized` | The API key is not there or is not correct. | Copy the key from **Settings → Organization**. Send it as `Authorization: Bearer` or `?api_key=`. | | `422 Unprocessable Entity` | The body does not match the schema. | Read the `detail` field in the response. Make sure that each event has `event_name`, `event_time`, `action_source`, and `user_data`. | | A retry adds no new conversion | The retry has the same `event_id` and `event_name` in 48 hours. | This is correct for retries. Use a different ID for each new conversion. | | Events arrive but have no attribution | The events have no attribution signals that match an ad. | This is correct for users who did not click a Gravity ad. For ad conversions, send the `getCAPIData()` data (it contains `grclid`) and the identity fields (`em`, `external_id`). | | No recent CAPI event in Events Manager | The request failed, or it used test mode. | Look at your backend logs, the API key, and the payload. Remove `test_event_code`. A 200 `accepted` response only confirms receipt, so also look in Events Manager. | ## Next > **[Pixel setup](/advertisers/pixel)** > > Install the Gravity pixel on your site. # Tracking CLI Source: https://docs.trygravity.com/advertisers/tracking-cli Set up the Pixel and server-side conversion tracking with your coding agent. The Gravity Tracking CLI (`@gravity-ai/tracking`, command `gravity-tracking`) gives your coding agent a setup plan for your website or app. Your agent follows the plan and edits your project. You review and deploy the changes. The CLI reads your tracking configuration only. It does not edit files, deploy code, set secrets, change provider settings, or send conversion events. ## Before you start * Node.js 20 or later. * A coding agent that can run commands in the source project of your website or app. * One of these credentials: * A Gravity login as an **owner** or **admin** of an advertiser account, for the browser login. * Your advertiser API key, for the `GRAVITY_API_KEY` environment variable or `login --api-key`. ## Set up with one prompt Prompt: set up Gravity tracking Copy it, then paste it into your coding agent. 1. Open your website's source project in your coding agent. 2. Copy the prompt above and paste it into the agent. It asks for your advertiser account name first. The plan tells the agent to do these tasks: * Confirm the project and the advertiser account. * Keep the tracking that already works. * Find the customer actions that your project has. * Prepare the Pixel and CAPI code. The event list gives candidates only. Your project decides which actions apply. The agent reports missing settings and the decisions that you must make. ## Install and sign in 1. Install the CLI: ```bash theme={null} npm install -g @gravity-ai/tracking ``` To run it without a global install, use `npx --yes @gravity-ai/tracking` in place of `gravity-tracking`. 2. Sign in: ```bash theme={null} gravity-tracking login ``` Your browser opens [app.trygravity.com](https://app.trygravity.com). Sign in, select your advertiser account, and select **Authorize CLI**. The CLI waits up to 5 minutes for you. 3. Make sure that the CLI uses the correct account: ```bash theme={null} gravity-tracking accounts ``` The output is `Account: `. This command does not change accounts. To use a different account, run `login` again and select that account. ### How the CLI stores credentials The browser login gives the CLI a read-only token for one advertiser account. The token expires after 24 hours. When it expires, run `gravity-tracking login` again. The CLI saves the token in the credential store of your operating system (macOS Keychain, Windows Credential Manager, or Linux Secret Service). It does not write the token to a project file. A saved login works only on the computer where you signed in. A remote coding agent must sign in, or get `GRAVITY_API_KEY`, in its own environment. ### Use an API key For CI or a headless environment, set your advertiser API key in the `GRAVITY_API_KEY` environment variable. This key overrides a saved login, and the CLI does not save it. To exchange the API key for a read-only token, run `gravity-tracking login --api-key`. Type the key at the hidden prompt. The CLI saves the token, not the key. > [!WARNING] > > Keep API keys out of prompts, source code, logs, and mobile apps. The CLI token is not a CAPI key. Your deployed backend uses your API key, not the CLI token. ## Choose a setup Run the commands in the source project where your agent makes the changes. | Command | What your agent prepares | | - | - | | `gravity-tracking setup full --json` | The website Pixel, the attribution handoff, and confirmed server-side conversions. `setup` with no mode does the same. | | `gravity-tracking setup pixel --json` | The website Pixel only. | | `gravity-tracking setup capi --json` | Confirmed server-side conversions, and the attribution handoff where possible. | | `gravity-tracking setup app --json` | iOS or Android tracking through your backend or a mobile measurement partner (MMP). | For website and app setup together, use `setup full --surface both --json`. The agent finds the platforms and the attribution provider in your app. You supply the provider settings that the agent cannot find. For more about app setup, see [App installs](/advertisers/app-installs). `--json` prints the full plan for your agent and shows no prompts. The plan never contains your API key. It does not install tracking. If an app plan has missing prerequisites, `setup --json` exits with code `2` and lists them. Without `--json`, the CLI shows the missing prerequisites as an error. ### Select events A **conversion event** is the name of a confirmed customer action, for example a completed purchase or a registration. A **custom event** is your own exact name for an action, for example `demo_booked`. ```bash theme={null} # Use these standard events as candidates. The agent connects only the ones that apply. gravity-tracking setup full --events Purchase,CompleteRegistration --discover-events --json # Request a custom event with its current name and a description of the action gravity-tracking setup full --custom-event "demo_booked=A demo booking is confirmed" --json # Keep a name that contains punctuation exactly as written gravity-tracking setup capi --custom-event-name "Plan=Pro" --json ``` * `--events` without `--discover-events` requests exactly the events that you give. * With `--discover-events` and no `--events`, the website candidates are `Purchase`, `Lead`, `CompleteRegistration`, `Subscribe`, `StartTrial`, `InitiateCheckout`, `AddToCart`, `Contact`, and `Schedule`. The app candidates are `AppInstall`, `AppOpen`, and `InAppPurchase`. * The plan keeps custom names exactly. It never changes a custom event into a standard event. * To request more actions, repeat `--custom-event`, `--custom-event-name`, or `--goal "Business action"`. * An event name can have at most 100 characters. A goal can have at most 500 characters. ### Shopify [Connect the Gravity Shopify app](/advertisers/integrations#shopify) for the Pixel and purchase tracking on your Shopify store. The CLI keeps that tracking. Do not add a second sender for Shopify purchases. To add custom actions, use `--custom-event` or `--custom-event-name` without `--discover-events`. ## Review and deploy 1. Read the changes that your agent made, and the list of missing inputs. 2. Make sure that the website changes keep your consent rules. 3. Make sure that the browser code reads attribution with `getCAPIData()`. 4. Make sure that the backend sends CAPI events only after a confirmed action, with a stable event ID. 5. Add your Gravity API key to the environment of your backend host. 6. Deploy the changes. 7. Verify the events in Events Manager. Follow the [Pixel guide](/advertisers/pixel) and the [CAPI guide](/advertisers/server-conversions). A plan or a successful local build does not show that events arrive. Only Events Manager shows that. `--hosted-pr` and `resume` print the link to the setup page in Events Manager. They do not run an installer from the CLI. ## Command reference | Command | Purpose | | - | - | | `gravity-tracking --help` | Show all commands and options. | | `gravity-tracking login` | Sign in through your browser and save a read-only token. | | `gravity-tracking login --no-browser` | Print the login URL. Open it on the same computer. | | `gravity-tracking login --api-key` | Exchange an API key, typed at a hidden prompt, for a read-only token. | | `gravity-tracking accounts` | Show the connected advertiser account. | | `gravity-tracking setup [pixel\|capi\|full\|app]` | Get the setup plan. The default mode is `full`. | | `gravity-tracking status` | Show the state of the latest setup runs. | | `gravity-tracking resume` | Print the link to the setup page in Events Manager. | | `gravity-tracking logout` | Revoke CLI access and remove the saved token. | `logout` does not revoke your API key, and it does not clear `GRAVITY_API_KEY`. ### Setup options | Option | Purpose | | - | - | | `--json` | Print the full plan as JSON for your agent. | | `--event NAME` | Request one exact, case-sensitive event. | | `--events NAME,NAME` | Request more than one standard event. | | `--discover-events` | Use the standard event names as candidates. | | `--custom-event NAME=GOAL` | Request a custom event. Repeat for each event. | | `--custom-event-name NAME` | Request a custom event name exactly as written. Repeat for each name. | | `--goal "Business action"` | Request an action in plain language. Repeat for each goal. | | `--surface website\|app\|both` | Select the website, the app, or both. | | `--platforms ios,android` | Select the app platforms. The agent detects them where possible. | | `--provider NAME` | Select `server`, `adjust`, `appsflyer`, `kochava`, `branch`, or `singular`. | | `--ios-tracker URL`, `--android-tracker URL` | Give the Adjust tracker link for each platform. | | `--event-tokens NAME=TOKEN` | Give Adjust event tokens, separated by commas. Use `ios:NAME=TOKEN` for one platform. | | `--ios-app-id ID`, `--android-app-id ID` | Give the real iOS bundle ID or Android application ID. | | `--framework NAME` | Override the framework that the CLI detects. | | `--account NAME` | Stop if the credential belongs to a different account. | | `--hosted-pr` | Print the link to the setup page in Events Manager. | | `--non-interactive` | Never show a prompt. | ### Exit codes | Code | Description | | - | - | | `0` | The command completed. | | `1` | An error occurred, or `status` found a failed run. | | `2` | `setup --json` found missing app prerequisites, or `status` found a run that waits for `GRAVITY_API_KEY` on your server. | ### API endpoint The CLI connects to `https://api-interface.trygravity.ai` by default. Use the default for the browser login. To use a different endpoint, set `--api-url` or the `GRAVITY_INTERFACE_API_URL` environment variable. ## Troubleshoot | Message | What to do | | - | - | | `Invalid or expired credential` | Your token expired after 24 hours, or the API key is wrong. Run `gravity-tracking login` again, or update `GRAVITY_API_KEY`. | | `Access denied` | The login or key does not belong to an advertiser account that you own or administer. Sign in as an owner or admin. | | `Too many logins for this account` | Wait one minute, then run `login` again. | | `No active Pixel for this account` | Open Events Manager and make a Pixel. Then run `setup` again. | | `Saved login uses a legacy API key` | Run `gravity-tracking login` again. | | `Cannot access the OS credential store` | Unlock the credential store and try again, or set `GRAVITY_API_KEY`. | | `Missing GRAVITY_API_KEY or saved login` | Run `gravity-tracking login` in this environment, or set `GRAVITY_API_KEY`. | # Docs MCP server Source: https://docs.trygravity.com/agents/docs-mcp Let an AI tool search and read the Gravity docs. It has no access to your account. The docs MCP server gives an AI tool two abilities: search the Gravity docs, and read any page in full. It is public, needs no API key, and cannot see or change anything in your Gravity account. To let an agent read reports or change your account, use the dashboard MCP server for [advertisers](/advertisers/dashboard-mcp) or [publishers](/ai-platforms/dashboard-mcp) instead. ## Connect 1. In your AI tool, add a remote MCP server with the URL `https://docs.trygravity.com/mcp`. Leave authentication empty. 2. Ask the tool to list its tools. You should see `search_gravity` and `query_docs_filesystem`. Each tool has its own steps for adding a server. Check its documentation if you can't find the setting. ## What the tools do | Tool | What it does | | - | - | | `search_gravity` | Searches the docs and returns the matching pages. | | `query_docs_filesystem` | Reads the full text of a page. | # Docs for AI tools Source: https://docs.trygravity.com/agents/overview Let your coding agent or AI assistant read and search these docs. Coding agents and AI assistants give better answers when they read the docs instead of guessing. You can give them access in a few ways. None of them needs an API key, and none of them can see or change your Gravity account. ## Share a single page Every page here is also available as plain Markdown. Add `.md` to the end of the URL and paste the link into your chat. For example, `https://docs.trygravity.com/advertisers/quickstart.md` is the advertiser quickstart. ## Share all the docs Two files cover the whole site: | File | What it contains | | - | - | | [`llms.txt`](https://docs.trygravity.com/llms.txt) | A list of every page, with a link and a one-line summary. Good for letting an agent pick the pages it needs. | | [`llms-full.txt`](https://docs.trygravity.com/llms-full.txt) | The full text of every page in one file. Good for tools that load a whole site at once. | ## Let the agent search the docs If your tool supports MCP (the Model Context Protocol, a standard way for AI tools to connect to outside services), connect the [docs MCP server](/agents/docs-mcp). The agent can then search the docs and open pages on its own while it works. ## Use the setup skill The setup skill is a short instruction file written for agents. It tells the agent which part of Gravity fits the task and where the relevant docs are. Prompt: set up Gravity with the skill Copy it, then paste it into your coding agent. For prompts tailored to your role, see the [advertiser quickstart](/advertisers/quickstart#option-a-set-up-with-an-ai-agent) or the [publisher quickstart](/ai-platforms/quickstart#set-up-with-a-coding-agent). ## Managing your account is separate Everything on this page is read-only access to public docs. To let an agent read your reports or make changes in your account, use the dashboard MCP server for [advertisers](/advertisers/dashboard-mcp) or [publishers](/ai-platforms/dashboard-mcp). That server needs your API key and has full admin access, so read its safety notes first. # Ad formats Source: https://docs.trygravity.com/ai-platforms/ad-formats The ad formats Gravity serves to AI platforms, what each looks like, and how to show it. Gravity serves four ad formats. The format depends on the ad and on what your client can draw, and the official SDK components draw all of them. You choose where an ad goes with [placements](/ai-platforms/placements). | Format | What it looks like | What you need | | - | - | - | | [Text](#text) | Brand, headline, ad text, and a call to action | Nothing. Every client can show it. | | [Server-rendered design](#server-rendered-design) | A layout that Gravity manages for the placement, such as a card | An official SDK component | | [Image](#image) | A text ad with the brand image beside or above the copy | An official SDK component, and an ad with an image | | [Lead form](#lead-form) | A form that collects the user's contact details for the advertiser | `GravityAd` with an API key and the session ID | All formats carry a "Sponsored" label and use the same tracking URLs (`impUrl` and `clickUrl`). ## Text The base format, and the fallback for every other one. When a richer format doesn't apply (for example, the ad has no image), Gravity sends a text ad. If you draw ads with your own component, set `supportsSpec: false` and `enableFrames: false` in the `Gravity` options so you get only text ads. See [Show an ad with your own component](/ai-platforms/show-ads#show-an-ad-with-your-own-component). ## Server-rendered design Gravity sends a layout for the placement in the ad's `renderer_spec` field, and the SDK draws it. Gravity can change the design of a placement without a code change or an SDK upgrade on your side. * **Supported clients:** `GravityAd` in React and React Native, `SpecAdView` on iOS, and `SpecAd` on Android. The SDKs tell Gravity that they can draw designs, so you don't set anything. * **Fallback:** if the design isn't compatible with the client, the SDK shows a built-in card. * **Custom design:** to get a design made for your placement, email [support@trygravity.com](mailto:support@trygravity.com). See [Server-rendered ads](/sdks/server-rendered-ads) for setup on each platform. ## Image When an ad has a brand image, Gravity can send an image layout: the image beside the copy or above it. Gravity selects the layout for each publisher. Ads without an image use the text format. Image ads need a client that draws server-rendered designs: `@gravity-ai/react` 1.3.0, `@gravity-ai/ui` 0.3.0, `@gravity-ai/react-native` 0.2.0, or later. Older versions show the text format. To preview it, send a test request with `testAdFormat: "image"` and `alwaysReturn: true`. Add `adLayout` (`image_left`, `image_right`, or `image_top`) to see one layout. See [Image ads](/engine/contextual-ads#image-ads). ## Lead form Some campaigns use a lead form: the ad collects the user's contact details and sends them to the advertiser. The ad response then has a `leadForm` object. `GravityAd` draws the form. To let it submit leads, pass the API key and the session ID of the ad request: ```tsx theme={null} ``` The `apiKey` prop is visible in the browser. Send it from your server with the ad response. See [Lead form ads](/ai-platforms/show-ads#lead-form-ads). To preview it, send a test request with `testAdFormat: "lead_form"`. Gravity doesn't store leads from the sample form. # Dashboard API Source: https://docs.trygravity.com/ai-platforms/dashboard-api Read your earnings and create placements from code with your API key. The Dashboard API gives you the numbers from your publisher dashboard over REST: revenue, impressions, placements, devices, and geography. Use it from scripts, scheduled jobs, or your own tools. The reporting endpoints are read-only, and one endpoint creates placements. This page walks through the common tasks. For every endpoint, parameter, response field, and error, see the [Publisher Dashboard API reference](/api-reference/publisher/overview). ## Base URL ``` https://platform.trygravity.com ``` ## Authentication Send your Gravity API key in the `X-API-Key` header on every request. To find it, open **Settings** in the [dashboard](https://app.trygravity.com) and copy the key from the **API Key** card. It's the same key you use to request ads. ```bash theme={null} curl https://platform.trygravity.com/publisher-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` > [!WARNING] > > The key has full access to your publisher account. Keep it on your server. Your account has one key, shared by ad requests, the Dashboard API, and the dashboard MCP server. Regenerating it stops the old key everywhere at once. ## Read your earnings `GET /publisher-dashboard/stats` returns a daily series of impressions, clicks, revenue, CPM, CPC, and CTR, plus totals. The numbers match the **Overview** and **Earnings** pages of the dashboard. ```bash theme={null} curl "https://platform.trygravity.com/publisher-dashboard/stats?days=7&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` Use `days` to look back from today, or `start_date` and `end_date` (`YYYY-MM-DD`) for a fixed range. A date range replaces `days`. Set `tz` to the IANA time zone you want the daily buckets in. The default is `UTC`. ```python Python theme={null} import os import requests API_KEY = os.environ["GRAVITY_API_KEY"] BASE = "https://platform.trygravity.com/publisher-dashboard" stats = requests.get( f"{BASE}/stats", headers={"X-API-Key": API_KEY}, params={"days": 30, "tz": "America/New_York"}, ).json() print(f"Impressions: {stats['totalImpressions']:,}") print(f"Revenue: ${stats['totalRevenue']:.2f}") print(f"eCPM: ${stats['avgCpm']:.2f}") ``` ```javascript JavaScript theme={null} const API_KEY = process.env.GRAVITY_API_KEY; const BASE = "https://platform.trygravity.com/publisher-dashboard"; const res = await fetch(`${BASE}/stats?days=30&tz=America/New_York`, { headers: { "X-API-Key": API_KEY }, }); const stats = await res.json(); console.log(`Impressions: ${stats.totalImpressions.toLocaleString()}`); console.log(`Revenue: $${stats.totalRevenue.toFixed(2)}`); console.log(`eCPM: $${stats.avgCpm.toFixed(2)}`); ``` ## Break down your results | To get | Call | | - | - | | Results for each placement, with a daily series | [`GET /publisher-dashboard/placements`](/api-reference/publisher/placements#get-/publisher-dashboard/placements) | | Results for each device type | [`GET /publisher-dashboard/devices`](/api-reference/publisher/reports#get-/publisher-dashboard/devices) | | Results for each country | [`GET /publisher-dashboard/geography`](/api-reference/publisher/reports#get-/publisher-dashboard/geography) | | Your top countries by requests, with unique users | [`GET /publisher-dashboard/countries`](/api-reference/publisher/reports#get-/publisher-dashboard/countries) | | The ad request funnel: requests, wins, impressions, fill rate, and show rate | [`GET /publisher-dashboard/activity`](/api-reference/publisher/reports#get-/publisher-dashboard/activity) | | Revenue per user by day, new and returning users, country, signup week, and your user tags | [`GET /publisher-dashboard/user-analytics`](/api-reference/publisher/revenue) | | The monthly statement of a qualifying-request agreement | [Agreement statements](/api-reference/publisher/revenue#agreement-statements) | If Gravity has turned on the user value API for your account, you can also rank your users by usage and value and look up one user's group. See [Users and segments](/api-reference/publisher/users-and-segments). Each endpoint has its own range limit for `days`. The reference lists them. ## Create a placement A placement is an ad slot. You can create one without opening the dashboard: ```bash theme={null} curl -X POST https://platform.trygravity.com/publisher-dashboard/placements \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Sidebar Slot", "placement_type": "right_response", "device": "desktop", "page_url": "https://myplatform.com/chat" }' ``` The response is `201` with the new placement. Its `slug` (here `Sidebar-Slot`) is the `placement_id` you send in ad requests. The placement starts as `active`, but Gravity refreshes its placement cache every 5 minutes, so ad requests get no ad for the new slot until the next refresh. Gravity builds the slug from `name`, and the slug must be unique in your account. A duplicate returns `409`. ## Errors A missing or invalid key returns `401`. A bad parameter, such as an unknown time zone or a date range that's too long, returns `400`. A request that fails validation returns `422`. The [reference](/api-reference/publisher/errors) lists every status. ## MCP server An AI agent can use this API through the dashboard MCP server at `https://platform.trygravity.com/mcp`. The server uses the same API key, so an agent can read reports and also act in the dashboard. The agent gets full admin access and actions run without confirmation. For setup, tools, and safety notes, see [Dashboard MCP server](/ai-platforms/dashboard-mcp). ## Questions Email [support@trygravity.com](mailto:support@trygravity.com) with any API question. # Dashboard MCP server for publishers Source: https://docs.trygravity.com/ai-platforms/dashboard-mcp Connect an AI agent to your publisher account. The agent can read earnings reports and manage placements. The dashboard MCP server lets an AI agent work in your Gravity publisher account. It can read reports and do what you can do in the publisher dashboard. > [!WARNING] > > Your API key gives an agent full admin access to your publisher account, including changes that affect the ads your app shows and your revenue. Read [Access and safety](#access-and-safety) before you connect one. ## Access and safety An agent that connects with your API key acts as an admin of your publisher account. It can: * Change placements and other publisher settings. These changes affect the ads your app shows and what you earn. * Add and remove team members and invites. The server carries out each call right away. It does not ask you or the agent to confirm anything first. Your account has one API key, and every integration and agent shares it. When anyone regenerates the key, the old key stops working at once for all of them. To cut off one agent, regenerate the key and give the new key only to the integrations you want to keep. Don't paste the key into a shared chat or a shared file. ## Before you start * A Gravity publisher account. * The API key of the publisher account. Get it in the dashboard. * An MCP client that can send JSON-RPC requests over HTTP POST. See "Client support" below. ## Connect 1. Get the API key of your publisher account in the dashboard. 2. Add `https://platform.trygravity.com/mcp` as an MCP server in your client. 3. Set the `X-API-Key` header to your API key. 4. Ask the client to list the tools. You see the `publisher_*` tools, the `dashboard_*` tools for publishers, and the account tools. ## Tools for publishers **Reporting tools** return summaries. They do not change data: `publisher_get_info`, `publisher_get_stats`, `publisher_get_activity`, `publisher_get_placements`, `publisher_get_devices`, `publisher_get_geography`, and `publisher_get_countries`. **Dashboard tools** do the actions of the publisher dashboard. Each tool name is `dashboard_` and the name of a dashboard action. They cover placements, statistics, payouts, and publisher requirements. Call `tools/list` to get the full list for your key. **Account tools** cover the team, organizations, support, Slack, and the Gravity Index. ## Recipes Some publisher tasks need more than one call. The server sends these recipes to the client in its `initialize` response. **Compare with the previous period** Call the same metrics tool two times. Use the same length of time for each call. The second period ends when the first period starts. **Get earnings from the Gravity Index** Call `dashboard_list_attributions` with the side `"publisher"`. **Export to CSV** The tools return JSON, not CSV. The dashboard makes the CSV files for earnings and placements in the browser. Make the CSV from the JSON. ## What the server cannot do for publishers These actions are not available with an API key: * Change your password, two-factor authentication, or personal profile. * Create, list, switch, or close organizations. * Change the payout model. * Manage publisher products. Some connections return a URL. A person must open the URL in a browser. File uploads have no MCP tool. Use the REST API under `https://platform.trygravity.com/dashboard`. ## Connection details | Item | Value | | - | - | | URL | `https://platform.trygravity.com/mcp` | | Other host | `https://platform.trygravity.ai/mcp` also works. | | Method | `POST` (JSON-RPC 2.0) | | Auth | `X-API-Key: YOUR_API_KEY`, or `Authorization: Bearer YOUR_API_KEY` | | Protocol version | `2024-11-05` | | Server name and version | `gravity-dashboard`, `2.0.0` | | Batch limit | 50 messages in one request | | Body limit | 256 KiB for each request | Advertiser accounts and publisher accounts use the same server. The API key tells the server which account you use, and the server shows only the tools for that account type. Every request needs the API key, also `initialize` and `tools/list`. A request without a valid key gets HTTP 401. To test the connection without a client, send a `tools/list` request: ```bash theme={null} curl https://platform.trygravity.com/mcp \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' ``` The response is a JSON-RPC result with a `tools` list. > [!NOTE] > > This page describes version `2.0.0` of the server. Tool names and tool groups can change in a later version. Use `tools/list` to get the current names. ## Results and errors * Each tool returns the JSON response of the dashboard action as text in `content`. * If the dashboard action fails, the result has `isError: true` and the text `HTTP : `. * A key can only get to its own account. A request for an account, organization, or publisher of a different key returns 404. One key cannot get to a different organization of the same owner. ## Client support The server uses JSON-RPC 2.0 over HTTP POST, with protocol version `2024-11-05`. It does not use the Streamable HTTP transport or Server-Sent Events. Some MCP clients need one of these transports and cannot connect. Gravity has not tested specific MCP clients with this server. Before you rely on a client, connect it and do a `tools/list` call. ## Troubleshoot | Problem | Cause | Fix | | - | - | - | | HTTP 401, "Not authenticated" | The request has no API key, or the key is not valid. | Send the key in `X-API-Key`. Make sure that nobody regenerated the key. | | HTTP 404 from a tool | The ID belongs to a different account or organization. | Use an ID from your own account. | | HTTP 413, "Request too large" or "Batch too large" | The body is larger than 256 KiB, or the batch has more than 50 messages. | Send smaller requests. | | The client cannot connect | The client needs Streamable HTTP or Server-Sent Events. | Use a client that sends JSON-RPC over HTTP POST. | | A tool is not in the list | The tool is for the other account type, or the tool name changed. | Use a key of the correct account type. Call `tools/list` again. | ## Related pages * [Dashboard MCP server for advertisers](/advertisers/dashboard-mcp) uses the same server with an advertiser key. * [Publisher Dashboard API](/ai-platforms/dashboard-api) gives the same data over REST. * [Docs MCP server](/agents/docs-mcp) searches the docs. It cannot read or change your account. # Experiments Source: https://docs.trygravity.com/ai-platforms/experiments Gravity tests ad designs on your traffic. You do not change your code. This page tells you how Gravity experiments work and what you must do for them. An **experiment** shows different ad designs to different users and measures which design performs best. The Gravity team sets up and monitors experiments. ## Before you start * You show ads with a Gravity SDK component, for example `GravityAd` from `@gravity-ai/react`. See [Show ads](/ai-platforms/show-ads). * You send a stable `sessionId` on each ad request. See [Request ads](/ai-platforms/request-ads). ## Integration You do not need code for experiments. Show the ad with the standard SDK component: ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; ``` Gravity selects the design for each request on its servers. A session gets the same design for all its requests. Impression tracking, click tracking, and attribution do not change. If you show ads with your own component, Gravity cannot change the design. Your ads then do not take part in design experiments. ## Start an experiment Send an email to [support@trygravity.com](mailto:support@trygravity.com) to discuss an experiment. # Going live Source: https://docs.trygravity.com/ai-platforms/going-live The checklist to do before you change to production mode and serve real ads. This page gives the checks to do before you serve real ads, and the steps after you go live. **Production mode** (`production: true`) tells the SDK to request real ads. In test mode, the SDK requests test ads, which are not billed. ## Before you start * Gravity approved your platform for real ads. See [Guidelines](/ai-platforms/publisher-requirements). Until approval, a production ad request returns `403` with the error `publisher_not_approved`. * Your integration works in test mode. See the [Quickstart](/ai-platforms/quickstart). ## Pre-flight checklist Do each of these checks in test mode, from start to end: **1. The API key is on your server** Set `GRAVITY_API_KEY` on your production server. The SDK reads it automatically. If you keep secrets in a different way, use `new Gravity({ apiKey: '...' })` in JavaScript or `Gravity(api_key=...)` in Python. **2. Ads show in each placement** For each `placement_id` that you request, make sure that the ad shows in the correct position in the UI. To always get a test ad, use `new Gravity({ alwaysReturn: true })` in JavaScript. Make sure that you registered each placement. See [Placements](/ai-platforms/placements). **3. Impressions fire when the ad is visible** Scroll the ad into view. The **Network** tab must show a request to `api.trygravity.ai/ack`. `GravityAd` sends it automatically. If you use your own component, request `ad.impUrl` when the ad becomes visible, for example `new Image().src = ad.impUrl`. **4. Clicks go through tracking** Click the ad. The first request must go to `api.trygravity.ai/track/click`, and it must return a `302` redirect to the advertiser's URL. Do not link to `ad.url`. Always use `ad.clickUrl`. **5. The Gravity pixel is installed** Install the [Gravity pixel](/ai-platforms/pixel) on your site. The pixel is required for attribution and payouts. If your native app opens ad links in an in-app browser, also do the [in-app browser install](/ai-platforms/pixel#in-app-browser-webview) in your WebView. **6. Session IDs are stable** All the ad requests from one conversation must have the same `sessionId`. Gravity uses it for frequency caps and experiment groups. **7. Device signals get to Gravity** Make sure that each ad request body has a `device` object with the end user's `ua` **and** `ip`. Without them, fraud and bot detection and the **Device** and **Geography** reports do not work for that request. `gravityContext()` collects these values in the browser. Make sure that your server **forwards** them to the ad request. See [Device signals](/ai-platforms/request-ads#device-signals). **8. Error handling works** Block the network requests to Gravity. Your UI must continue to show, and your LLM stream must continue to work. No errors must get to the user. ## Change to production mode ```ts Node / JS / TS theme={null} const gravity = new Gravity({ production: true }); ``` ```python Python theme={null} gravity = Gravity(production=True) ``` This is the only code change. In production mode, the engine returns real ads from real campaigns. Impressions and clicks count toward your revenue, and the data shows in your dashboard. ## After you go live **1. Look at the dashboard** Open [app.trygravity.com](https://app.trygravity.com) and select your AI platform account. Make sure that requests, impressions, clicks, and revenue show. **2. Monitor the fill rate** The **fill rate** is the number of ads served divided by the number of ads requested. A lower `relevancy` threshold gives a higher fill rate. A higher threshold gives a lower fill rate. See [Relevancy tradeoff](/ai-platforms/request-ads#relevancy-tradeoff). **3. Monitor latency** The JavaScript SDK stops the ad request after 4 seconds (`timeoutMs`). The Python SDK stops it after 3 seconds (`timeout`). If many requests stop at this limit, increase the value on the constructor. When an ad request stops, your LLM response shows with no ad for that turn. ## Roll back Set `production: false` on the constructor, and deploy. The SDK then requests test ads, and you stop earning revenue. Nothing else changes. ## Next steps > **[Payouts & analytics](/ai-platforms/payouts)** > > How Gravity pays you and what the dashboard shows. > **[Experiments](/ai-platforms/experiments)** > > How Gravity tests ad designs on your traffic. # Integration guide Source: https://docs.trygravity.com/ai-platforms/integration-guide The full AI platform process: design, integrate, test, install the pixel, go live, and get paid. This guide gives the full process to add Gravity ads to your AI product. For the short version, see the [Quickstart](/ai-platforms/quickstart). ## Before you start * You have a Gravity AI platform account and an API key. See [Quickstart, step 1](/ai-platforms/quickstart#1-create-your-account). * Your server calls your LLM and can send a second request at the same time. ## Process overview **1. Design** Decide where ads go in your UI and which placements you need. Decide if you use the SDK or the HTTP API. **2. Integrate** Install the SDK. Call `gravityContext()` on the client and `gravity.getAds()` on the server. Show the ad with `GravityAd` or with your own component. **3. Test** Keep production mode off. Make sure that each placement shows an ad, that impressions fire when the ad is visible, and that clicks go through `clickUrl`. **4. Install the pixel** Put the [Gravity pixel](/ai-platforms/pixel) on your site. The pixel creates a visitor ID, and the SDK sends this ID with each ad request. If your app opens ad links in a native in-app browser, also do the [in-app browser install](/ai-platforms/pixel#in-app-browser-webview). **5. Go live** Get approval from Gravity, then set `production: true`. Do the [going live checklist](/ai-platforms/going-live). **6. Optimize** Gravity can run [experiments](/ai-platforms/experiments) on ad designs with your real traffic. You do not change your code. **7. Get paid** Connect your payout account and monitor your [payouts](/ai-platforms/payouts) in the dashboard. ## Select placements A **placement** is the position of an ad in your UI. Each ad request declares one or more placements. These are the common types: | Placement | When to use it | | - | - | | `below_response` | Below the assistant message. This is the most common type in chat UIs. | | `above_response` | Above the response. Use it carefully. | | `inline_response` | In the response content (Markdown). | | `left_response` / `right_response` | In a sidebar next to the response. | | `search_result` | In a list of search results. | | `top_page` / `bottom_page` | At the top or bottom of the page. | Each placement also has a `placement_id`. This is your name for one ad slot, for example `"main"` or `"mobile-footer"`. Register each `placement_id` in the dashboard before you send it. Keep the names stable, because the dashboard reports results for each slot. See [Placements](/ai-platforms/placements). You can request 1 to 10 placements in one call. ## Select the SDK or the API The SDK and the API use the same ad engine. The SDK calls the API for you. It also collects the context, sends the end user's IP address, and handles timeouts and errors. **Use the SDK when:** * You use Node.js, TypeScript, Python, or React. * You want a ready ad component that Gravity can update from the server. * You want Gravity experiments with no code changes. * You want impressions and clicks to be tracked for you. **Use the API when:** * You use a language or runtime that has no Gravity SDK. * You show ads with your own components and cannot use `GravityAd`. * You build a CLI or another surface with no web UI. > [!NOTE] > > Direct API integrations require a signed agreement with Gravity. Send an email to [support@trygravity.com](mailto:support@trygravity.com) to request access. ## Framework examples ### Next.js (App Router) In a Next.js route handler, `req` is a Web `Request`. The SDK cannot read its body or headers directly. Parse the body first, then give the SDK an object with `body` and `headers`. ```ts theme={null} // app/api/chat/route.ts import { Gravity } from '@gravity-ai/api'; const gravity = new Gravity({ production: process.env.NODE_ENV === 'production' }); export async function POST(req: Request) { const body = await req.json(); const { messages } = body; const adPromise = gravity.getAds( { body, headers: Object.fromEntries(req.headers) }, messages, [{ placement: 'below_response', placement_id: 'main' }], ); const stream = new ReadableStream({ async start(controller) { for await (const chunk of callLLM(messages)) { controller.enqueue(`data: ${JSON.stringify({ type: 'chunk', content: chunk })}\n\n`); } const { ads } = await adPromise; controller.enqueue(`data: ${JSON.stringify({ type: 'done', ads })}\n\n`); controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }); } ``` ### Express ```ts theme={null} import { Gravity } from '@gravity-ai/api'; const gravity = new Gravity({ production: true }); app.post('/api/chat', async (req, res) => { const { messages } = req.body; const adPromise = gravity.getAds(req, messages, [{ placement: 'below_response', placement_id: 'main' }]); // ... stream LLM, await adPromise, append ads }); ``` ### FastAPI (Python) ```python theme={null} import asyncio from fastapi import Request from gravity_sdk import Gravity gravity = Gravity(production=True) @app.post("/api/chat") async def chat(request: Request): body = await request.json() ad_task = asyncio.create_task( gravity.get_ads(request, body["messages"], [ {"placement": "below_response", "placement_id": "main"} ]) ) # ... stream LLM, await ad_task, append ads ``` ## Error handling The SDK never throws an error. This table shows the result for each failure: | Problem | JavaScript result | Python result | | - | - | - | | No API key | `{ ads: [] }` with `status: 0`. The SDK sends no request. | `AdResult(ads=[])`. The SDK logs a warning when you create the client. | | Network error or timeout | `{ ads: [] }` with `status: 0` and `error` | `AdResult(ads=[])` with `error` | | No match (`204 No Content`) | `{ ads: [] }` with `status: 204` | `AdResult(ads=[])` with `status=204` | | Invalid API key (`401`) | `{ ads: [] }` with `status: 401` and `error` | `AdResult(ads=[])` with `status=401`. The SDK logs a warning. | | Platform not approved (`403`) | `{ ads: [] }` with `status: 403` and `error` | `AdResult(ads=[])` with `status=403`. The SDK logs a warning. | Make sure that your UI works with an empty `ads` array. Do not make your LLM response wait for the ad request. ## Go live See [Going live](/ai-platforms/going-live). In short: get approval, set `production: true`, make sure that impressions arrive, make sure that clicks go through `clickUrl`, and monitor the dashboard. ## Next steps > **[Request ads](/ai-platforms/request-ads)** > > The request fields, device signals, and tuning options. > **[Show ads](/ai-platforms/show-ads)** > > Show ads with the React component or your own component. > **[Pixel](/ai-platforms/pixel)** > > Put `gr-pix.js` on your site. The SDK sends the visitor ID for better attribution. > **[Experiments](/ai-platforms/experiments)** > > How Gravity tests ad designs on your traffic. # Payouts & analytics Source: https://docs.trygravity.com/ai-platforms/payouts How Gravity pays you, how to connect your payout account, and what your dashboard shows. This page tells you how you earn revenue, how to get paid, and what the dashboard shows. ## Revenue model You earn revenue from the ads that you show, on a **CPM** basis: revenue is counted per impression and reported per 1,000 impressions. An **impression** counts when the ad's `impUrl` is loaded. The SDK components load it once the ad is on screen; if you draw ads yourself, load it once when you show the ad. ## What changes your eCPM Your **eCPM** (effective CPM) is your revenue for each 1,000 impressions. It is not a fixed rate. Advertisers pay more for impressions that perform well. These items have an effect on your eCPM: * **Match quality.** Ads that match the conversation closely perform better, so advertisers pay more for them. Your [relevancy threshold](/ai-platforms/request-ads#relevancy-tradeoff) has a direct effect on match quality. * **CTR.** The click-through rate (CTR) is the part of impressions that get a click. Gravity designs and [experiments](/ai-platforms/experiments) try to increase it. * **Fill rate.** The fill rate is the part of ad requests that get an ad. More ads served give more revenue, if all other items stay the same. Your placements and your relevancy threshold change the fill rate. * **Data quality.** Send `messages`, a stable `sessionId`, a stable user ID, and a **hashed email** when you have one. A hashed email lets Gravity do view-through attribution, and advertisers pay more for it. See [Data quality](/ai-platforms/request-ads#data-quality). * **The pixel.** Gravity can attribute results across surfaces only when the [Gravity pixel](/ai-platforms/pixel) is installed. Examples are clicks that open in your in-app browser, and users who come back on a different device. Without the pixel, advertisers see a lower return, and they pay less for your traffic. The better your integration and data quality, the higher your eCPM. ## Dashboard Open [app.trygravity.com](https://app.trygravity.com) and select your AI platform account. The sidebar has these pages: | Page | What it shows | | - | - | | **Overview** | A summary of your traffic and results. | | **Placements** | Your placements and the results for each placement. See [Placements](/ai-platforms/placements). | | **Audience** | Data about your users and the request signals that need attention. Send [user tags and model usage](/ai-platforms/user-earnings) to see what each group of users earns compared with its model cost. | | **Earnings** | Your earnings for each day and for a selected period. | | **Settings** | Your API key, pixel snippet (**Tracking**), and payout account (**Payouts**). | ## Key metrics | Metric | What it measures | | - | - | | **Requests** | The ad requests that your app sent. | | **Ads served** | The requests that returned an ad. This does not include `204 No Content` responses. | | **Fill rate** | How often Gravity found a matching ad: ads served divided by requests. | | **Impressions** | The ads that sent the impression request (the ads that became visible). | | **eCPM** | Effective CPM: your revenue for each 1,000 impressions. | | **Revenue** | Your part of the ad spend. | ## Set up payouts Gravity pays you through Stripe. Connect a Stripe account to get payouts: 1. Open **Settings** > **Payouts** in the dashboard. 2. Click the button to connect Stripe, and select your country. 3. Complete the Stripe onboarding steps. When Stripe approves your details, the **Payouts** section shows that you are ready to get payouts. Stripe can take 1 to 2 business days to review your details. Gravity pays each month when your earnings are \$100 or more. If your balance is less than \$100, it moves to the next month. See [Guidelines](/ai-platforms/publisher-requirements). ## Dashboard API You can get your dashboard data (revenue, impressions, placements, devices, and geography) with code. Use the same API key that you use for ad requests. See the [Dashboard API reference](/ai-platforms/dashboard-api). ## Questions Send an email to [support@trygravity.com](mailto:support@trygravity.com) for questions about payouts and billing. # Pixel Source: https://docs.trygravity.com/ai-platforms/pixel Install the Gravity pixel on your website and in your in-app browser. The pixel is required for attribution, measurement, and payouts. This page tells you how to install the Gravity pixel and how to verify it. The **Gravity pixel** (`gr-pix.js`) is a script that records visits and sessions on your site. Gravity uses this data to connect ad impressions and clicks to real results. > [!WARNING] > > The pixel is required. Without it, Gravity cannot attribute conversions or calculate your payouts. Install it before you go live. The pixel also creates a visitor ID and a session ID. When the pixel is on the page, `gravityContext()` in `@gravity-ai/api` adds these IDs to each ad request. See [Request ads](/ai-platforms/request-ads). ## Before you start * You have a Gravity AI platform account. * You know your pixel ID. The dashboard shows the full snippet with your pixel ID in **Settings** > **Tracking**. The pixel ID is not your API key. ## Web install 1. Copy the snippet below. You can also copy it from **Settings** > **Tracking**, where it already has your pixel ID. 2. Replace `YOUR_PIXEL_ID` with your pixel ID. 3. Paste the snippet in the `` of every page. Put it in your base template or layout, so that each page has it. ```html theme={null} ``` **Settings** > **Tracking** also has ready snippets for React and Next.js. ### Verify that the pixel works 1. Open a page that has the pixel. 2. Open the browser developer tools, and go to the **Network** tab. 3. Find a request to `code.trygravity.com/gr-pix.js`. This is the pixel script. 4. Find a `POST` request to `api.trygravity.com/track/gr-events`. This request sends the pixel events. A `200` response to `/track/gr-events` tells you that Gravity received the events. The events can take some minutes to show in your dashboard. ## In-app browser (WebView) Do this install only if your app opens Gravity ad links in a WebView inside your app. Install the pixel in the WebView with the `inAppBrowser: true` setting. Then Gravity keeps the attribution when a user clicks an ad and opens the advertiser's page in your app. > [!TIP] > > If your app opens ad links in the system browser (for example with `UIApplication.shared.open(url)`), you do not need this install. The web install is sufficient. For each platform, add two scripts to the WebView: 1. A configuration script that runs at document start. It sets `window.GRAVITY_PIXEL_CONFIG` with `inAppBrowser: true` and your pixel ID. 2. The pixel script that runs at document end. **Settings** > **Tracking** shows these snippets with your pixel ID. ### Swift (iOS / iPadOS) Add both scripts to the `WKUserContentController` before you create the `WKWebView`. A `WKWebView` copies its configuration when you create it, so scripts that you add after that time do not run. ```swift theme={null} import WebKit let contentController = WKUserContentController() // Step 1: config at document start let configScript = WKUserScript( source: """ window.GRAVITY_PIXEL_CONFIG = { inAppBrowser: true, pixel_id: "YOUR_PIXEL_ID" }; """, injectionTime: .atDocumentStart, forMainFrameOnly: true ) contentController.addUserScript(configScript) // Step 2: pixel loader + init at document end let pixelScript = WKUserScript( source: """ !function(w,d,t,u,n,a,m){w['GravityPixelObject']=n;w[n]=w[n]||function(){ (w[n].q=w[n].q||[]).push(arguments)},w[n].l=1*new Date();a=d.createElement(t), m=d.getElementsByTagName(t)[0];a.async=1;a.src=u;m.parentNode.insertBefore(a,m) }(window,document,'script','https://code.trygravity.com/gr-pix.js','gravity'); gravity('init', 'YOUR_PIXEL_ID'); """, injectionTime: .atDocumentEnd, forMainFrameOnly: true ) contentController.addUserScript(pixelScript) let config = WKWebViewConfiguration() config.userContentController = contentController let webView = WKWebView(frame: .zero, configuration: config) ``` ### React Native This example uses `react-native-webview`. ```jsx theme={null} import { WebView } from 'react-native-webview'; const INJECT_BEFORE = ` window.GRAVITY_PIXEL_CONFIG = { inAppBrowser: true, pixel_id: "YOUR_PIXEL_ID" }; true; `; const INJECT_AFTER = ` !function(w,d,t,u,n,a,m){w['GravityPixelObject']=n;w[n]=w[n]||function(){ (w[n].q=w[n].q||[]).push(arguments)},w[n].l=1*new Date();a=d.createElement(t), m=d.getElementsByTagName(t)[0];a.async=1;a.src=u;m.parentNode.insertBefore(a,m) }(window,document,'script','https://code.trygravity.com/gr-pix.js','gravity'); gravity('init', 'YOUR_PIXEL_ID'); true; `; ``` ### Flutter This example uses the `flutter_inappwebview` package. ```dart theme={null} import 'dart:collection'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; final configScript = UserScript( source: ''' window.GRAVITY_PIXEL_CONFIG = { inAppBrowser: true, pixel_id: "YOUR_PIXEL_ID" }; ''', injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, ); final pixelScript = UserScript( source: ''' !function(w,d,t,u,n,a,m){w['GravityPixelObject']=n;w[n]=w[n]||function(){ (w[n].q=w[n].q||[]).push(arguments)},w[n].l=1*new Date();a=d.createElement(t), m=d.getElementsByTagName(t)[0];a.async=1;a.src=u;m.parentNode.insertBefore(a,m) }(window,document,'script','https://code.trygravity.com/gr-pix.js','gravity'); gravity('init', 'YOUR_PIXEL_ID'); ''', injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, ); InAppWebView( initialUserScripts: UnmodifiableListView([configScript, pixelScript]), ) ``` ## Troubleshoot > **The pixel does not load on a web page** > > * Make sure that the snippet is on the page, in the ``. > * In the **Network** tab, find the request to `code.trygravity.com/gr-pix.js`. If it is not there, a script blocker or a Content Security Policy can stop it. > * Look for errors in the browser console. An error in a different script can stop the scripts that come after it. > **The pixel loads, but no events show in the dashboard** > > * Make sure that the pixel ID in the snippet is the same as the pixel ID in **Settings** > **Tracking**. The pixel ID is not your API key. > * In the **Network** tab, find the `POST` requests to `api.trygravity.com/track/gr-events`. A `200` response tells you that Gravity received the events. The dashboard can take some minutes to show them. > **No events from the WebView** > > * On iOS, use the default `WKWebsiteDataStore.default()`. A `.nonPersistent()` data store does not keep cookies. > * Make sure that you added the scripts before you created the `WKWebView`. > * Make sure that the injected scripts run. Add a `console.log` to the injected script, and read it with `WKScriptMessageHandler` (Swift) or `onMessage` (React Native). ## Next steps > **[Going live](/ai-platforms/going-live)** > > The checklist to do before you set `production: true`. # Placements Source: https://docs.trygravity.com/ai-platforms/placements Placements are the ad slots in your UI. Register them, name them, and use them to tune fill, match quality, and the user experience. A **placement** is a named slot in your UI where an ad can show. This page tells you how to select placement types, register placements, and send them in ad requests. Placements control three things that have a large effect on your revenue: 1. **Fill**: how often a campaign is available for the slot. 2. **Match quality**: how related the ad is to the conversation. 3. **User experience**: how the ad looks and feels in your product. ## Why placements are important * **Results for each slot.** The dashboard shows impressions, CTR, and revenue for each `placement_id`. For example, a `below_response` slot and a `right_response` sidebar can earn very different amounts. You can see which slots in your UI earn the most. * **Experiments for one slot.** Gravity can run [experiments](/ai-platforms/experiments) on one placement only. For example, Gravity can test a new design in your sidebar and not change your main slot. * **Settings for each slot.** Each slot can have its own settings, for example a maximum ad text length (`max_ad_text_chars` in the request). ## Placement types Each placement in a request has a `placement` type. The type tells Gravity where the slot is in your UI. | Placement | Typical use | | - | - | | `below_response` | Below the assistant message. This is the most common type in chat UIs. | | `above_response` | Above the response. Use it carefully. | | `inline_response` | In the response content (Markdown). | | `left_response` | In a sidebar to the left of the response. | | `right_response` | In a sidebar to the right of the response. | | `search_result` | In a list of search results. | | `top_page` | At the top of the page. | | `bottom_page` | At the bottom of the page. | | `left_page` | In a fixed left column. | | `right_page` | In a fixed right column. | | `center_page` | In the center of the page, at full width. | ## Register placements Register each slot before you send it in an ad request. You can register a placement in two ways: * In the dashboard, on the **Placements** page. * With the API: [`POST /publisher-dashboard/placements`](/api-reference/publisher/placements#post-/publisher-dashboard/placements). A new `placement_id` in an ad request does **not** create a placement. > [!WARNING] > > Register a placement before you send it. When your account has one or more placements, the engine keeps only your active placements in each request. It accepts the slug or the UUID of the placement. If a request has registered and unregistered `placement_id` values, the engine removes the unregistered ones. Those slots get no ad, and the response shows no error. > > There are two exceptions, for backward compatibility. If your account has no placements, the engine does not filter. If no `placement_id` in the request is registered, the engine serves the request with all its placements. A new placement can take some minutes to become active, because Gravity refreshes the placement cache every 5 minutes. ## `placement_id`: your slot name Each placement needs a `placement_id` that identifies one slot: ```ts theme={null} const { ads } = await gravity.getAds(req, messages, [ { placement: 'below_response', placement_id: 'chat-main' }, { placement: 'right_response', placement_id: 'chat-sidebar' }, { placement: 'bottom_page', placement_id: 'footer' }, ]); ``` Use a stable string that identifies the slot in your UI, for example `'chat-main'`, `'mobile-footer'`, or `'search-result-3'`. Each `placement_id` must be unique in one request. Register it in the dashboard, and then use the same value each time. The dashboard reports revenue and CTR for each `placement_id`. ## Up to 10 placements in one request You can request ads for 1 to 10 placements in one call. All the placements use the same auction, so you can request all the slots on a page in one call. ## No ad is normal When no campaign matches a slot, that slot gets no ad. When no slot gets an ad, the engine returns `204 No Content` and the SDK gives an empty `ads` array. Find the ad for each slot by its `placement_id`, and show a slot only when it has an ad: ```tsx theme={null} const mainAd = ads.find((ad) => ad.placement_id === 'chat-main'); {mainAd && } ``` These items change the **fill rate** (the part of requests that get an ad): * How related your content is to the active campaigns. * The `relevancy` threshold that you set. See [Relevancy tradeoff](/ai-platforms/request-ads#relevancy-tradeoff). * The advertiser budget that is available at that time. To get more ads, set a lower `relevancy` threshold on the `Gravity` constructor. But closer matches usually convert better. ## Next steps > **[Experiments](/ai-platforms/experiments)** > > How Gravity runs experiments on your placements. # Guidelines Source: https://docs.trygravity.com/ai-platforms/publisher-requirements The AI Platform Guidelines for platforms that show Gravity ads, and the review before you go live. This page gives the AI Platform Guidelines and the review process before you serve real ads. You accept the guidelines when you sign up. ## Rules 1. When a Gravity ad is on the screen, no other ad can be on the screen. This includes ads from other networks or exchanges, and your own house or direct campaigns. 2. You must serve Gravity ads through a Gravity SDK. A direct API integration requires a signed agreement. 3. Ads can show only on websites and apps that Gravity verified. 4. Your platform must give a functional, well-designed product experience. Platforms of low quality, or platforms that exist mainly to show ads, are not eligible. 5. Traffic must be genuine. Bots, fake clicks, incentivized clicks, and other invalid traffic are not permitted. 6. Before you change an approved ad format, placement, or ad frequency, you must get approval from Gravity. 7. As the platform owner, you must obey the applicable law and respect the privacy choices of your users. 8. Gravity pays each month when your earnings are \$100 or more. If your balance is less than \$100, it moves to the next month. 9. If you do not obey these guidelines, the [Terms of Service](https://trygravity.com/terms), or the [Brand Safety Guidelines](https://trygravity.com/brand-safety), Gravity can suspend or close your account, or hold your payments. ## Review before you go live Gravity reviews each platform before real ads serve on it, as an app store does. The Gravity team examines your placement. 1. Accept the rules above. 2. Give the URL or the app where ads will show. 3. If users must log in to see the placement, give a test login (an email and a password, or an invite). Gravity encrypts the test login, and only the Gravity review team can see it. Test ads work during the review, so you can continue your integration. Until approval, a production ad request returns `403` with the error `publisher_not_approved`. Gravity sends you an email when it approves your platform for real ads and payouts. If Gravity cannot approve your platform, the email gives the reason. When the rules change, existing platforms go through the same review. You can continue to serve ads during a grace period. After the grace period, a platform without approval stops serving real ads until Gravity approves it. > [!NOTE] > > See [Going live](/ai-platforms/going-live) for the integration checklist. See [Payouts & analytics](/ai-platforms/payouts) for how Gravity pays you. # AI platform quickstart Source: https://docs.trygravity.com/ai-platforms/quickstart Show your first Gravity ad in your AI product. Create an account, install the SDK, request an ad, and show it. This guide shows you how to request a Gravity ad from your server and show it in your chat UI. An **AI platform** (also called a publisher) is a product that shows Gravity ads to its users. The guide uses the JavaScript SDK (`@gravity-ai/api` and `@gravity-ai/react`) and the Python SDK (`gravity-sdk`). To call the HTTP API directly, see the [API reference](/engine/introduction). With the API, you must show the ad and send the tracking requests yourself. ## Set up with a coding agent Using Claude Code, Cursor, or Codex? Open your project in the agent and paste this prompt. The agent follows this guide, and you review each change. Prompt: integrate Gravity ads Copy it, then paste it into your coding agent. ## Before you start * You have a web or mobile product with a chat or answer UI. * You have a server that calls your LLM. The ad request goes from this server. * For the JavaScript SDK, you have Node.js and npm. For the Python SDK, you have Python 3.10 or later. ## 1. Create your account 1. Go to [app.trygravity.com/platform/signup](https://app.trygravity.com/platform/signup). 2. Create an AI platform account for your product. 3. Open **Settings**. Copy the key from the **API Key** card. 4. Save the key as the `GRAVITY_API_KEY` environment variable on your server. > [!WARNING] > > Keep the API key on your server. Do not put it in client code or in a public repository. ## 2. Install the SDK ```bash Node / JS / TS theme={null} npm install @gravity-ai/api @gravity-ai/react ``` ```bash Python theme={null} pip install gravity-sdk ``` ## 3. Collect context on the client Call `gravityContext()` in your chat component. It returns a `gravity_context` object with the session ID, the user ID, and **device signals**. Device signals are data about the end user's device, for example the User-Agent, the time zone, and the locale. Send `gravity_context` to your server in the body of your chat request. ```ts theme={null} import { gravityContext } from '@gravity-ai/api'; const gravity_context = gravityContext({ sessionId: chatSession.id, // your conversation ID user: { userId: currentUser.id }, // your user ID (required) }); fetch('/api/chat', { method: 'POST', body: JSON.stringify({ messages, gravity_context }), }); ``` `gravityContext()` throws an error if `user.userId` is missing. It also throws an error if `sessionId` is missing and the [Gravity pixel](/ai-platforms/pixel) is not on the page. For signed-out users, create a stable anonymous install or device ID and keep it. Do not send a shared constant such as `"anonymous"`, because Gravity leaves it out of per-user analytics. > [!WARNING] > > Send `gravity_context` from the client to your server, and from your server to Gravity. If you do not, the ad request carries the IP address and User-Agent of your server, not of the end user. Gravity uses these values for fraud detection and for the **Device** and **Geography** reports. The SDK `getAds()` method reads `gravity_context` from the request body. It also adds the end user's IP address from the request headers. See [Device signals](/ai-platforms/request-ads#device-signals). ## 4. Request ads on the server Start the ad request at the same time as your LLM call. Do not wait for the ad before you stream the LLM response. The SDK never throws an error. If the request fails, the result has an empty `ads` array. ```ts Node / Express theme={null} import { Gravity } from '@gravity-ai/api'; const gravity = new Gravity(); // reads GRAVITY_API_KEY from env app.post('/api/chat', async (req, res) => { const { messages } = req.body; const adPromise = gravity.getAds(req, messages, [ { placement: 'below_response', placement_id: 'main' }, ]); // Stream your LLM response in parallel... for await (const token of streamYourLLM(messages)) { res.write(`data: ${JSON.stringify({ type: 'chunk', content: token })}\n\n`); } const { ads } = await adPromise; res.write(`data: ${JSON.stringify({ type: 'done', ads })}\n\n`); res.end(); }); ``` ```python Python / FastAPI theme={null} import asyncio import json from fastapi import Request from fastapi.responses import StreamingResponse from gravity_sdk import Gravity gravity = Gravity() # reads GRAVITY_API_KEY from env @app.post("/api/chat") async def chat(request: Request): body = await request.json() messages = body["messages"] ad_task = asyncio.create_task( gravity.get_ads(request, messages, [ {"placement": "below_response", "placement_id": "main"} ]) ) async def event_stream(): async for token in stream_your_llm(messages): yield f"data: {json.dumps({'type': 'chunk', 'content': token})}\n\n" ad_result = await ad_task ads = [a.to_dict() for a in ad_result.ads] yield f"data: {json.dumps({'type': 'done', 'ads': ads})}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") ``` A **placement** is the position of the ad in your UI, for example `below_response`. The `placement_id` is your name for one ad slot. See [Placements](/ai-platforms/placements). In test mode, the result is a test ad or an empty `ads` array. To always get a test ad in JavaScript, use `new Gravity({ alwaysReturn: true })`. Gravity ignores `alwaysReturn` in production. ## 5. Show the ad ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; function ChatResponse({ response, ads }) { return (

{response}

{ads[0] && }
); } ``` `GravityAd` shows the ad design and the "Sponsored" label. It sends the impression when the ad is visible, and it sends clicks through the tracking URL. You see the ad below the response. ## 6. Install the pixel The Gravity pixel (`gr-pix.js`) is a script for your website. It is required for attribution and payouts. Without it, Gravity cannot connect ad results to visits and conversions. Put this script in the `` of every page. Replace `YOUR_PIXEL_ID` with your pixel ID. The dashboard shows the full snippet with your ID in **Settings** > **Tracking**. ```html theme={null} ``` To verify the pixel, see [Pixel](/ai-platforms/pixel). If your native app opens ad links in a WebView, also do the [in-app browser install](/ai-platforms/pixel#in-app-browser-webview). ## 7. Go live By default, `new Gravity()` requests **test ads**. Test ads are not billed and do not earn revenue. Before you serve real ads, Gravity must approve your platform. See [Guidelines](/ai-platforms/publisher-requirements). After approval, set production mode: ```ts Node / JS / TS theme={null} const gravity = new Gravity({ production: true }); ``` ```python Python theme={null} gravity = Gravity(production=True) ``` You now serve real ads and earn revenue. Before you deploy, do the [going live checklist](/ai-platforms/going-live). > **[Integration guide](/ai-platforms/integration-guide)** > > The full process: placements, framework examples, and error handling. > **[Going live checklist](/ai-platforms/going-live)** > > The checks to do before you set `production: true`. ## What the SDK does for you * **Timeout**: the request stops after 4 seconds in the JavaScript SDK (`timeoutMs`) and after 3 seconds in the Python SDK (`timeout`). You can change these values. * **No errors to catch**: `getAds()` and `get_ads()` never throw. A failure gives an empty `ads` array. * **Impressions and clicks**: `GravityAd` sends them for you. * **Experiments**: Gravity can test ad designs on your traffic without code changes. See [Experiments](/ai-platforms/experiments). * **Ad selection**: Gravity selects the ad and writes the ad text. You do not select advertisers. ## Troubleshoot > **The ads array is empty** > > This is normal when no campaign matches the conversation. The engine returns `204 No Content`, and the SDK gives `{ ads: [] }`. Hide the ad slot. To get more ads, use a lower [relevancy threshold](/ai-platforms/request-ads#relevancy-tradeoff). Also make sure that `GRAVITY_API_KEY` is set. Without a key, the SDK returns an empty array and does not send a request. > **Production requests return status 403** > > Gravity did not approve your platform yet. The engine returns `403` with the error `publisher_not_approved`. Test ads continue to work. See [Guidelines](/ai-platforms/publisher-requirements) for the review steps. > **You see the same ad on every request** > > You are in test mode. Set `production: true` in JavaScript or `production=True` in Python. > **The ad shows, but impressions do not count** > > `GravityAd` sends the impression when at least half of the ad is in the viewport. Make sure that the ad is visible on the screen. If you use your own component, you must send the impression yourself. Request `ad.impUrl` one time when the ad becomes visible, for example `new Image().src = ad.impUrl`. # Request ads Source: https://docs.trygravity.com/ai-platforms/request-ads Request ads from your server with the SDK or the HTTP API: request fields, device signals, user data, response, and options. This page tells you how to request ads from your server. It gives the request fields, the response, and the options that change which ads you get. ## Before you start * You have an API key. See [Quickstart](/ai-platforms/quickstart#1-create-your-account). * You registered your placements in the dashboard. See [Placements](/ai-platforms/placements). ## The pattern Start the ad request at the same time as your LLM call. There are two reasons: 1. **Latency.** The user waits for your LLM response, not for the ad. The ad request must not delay the response. 2. **Safe failure.** If the ad request fails, the chat continues with no ad. > [!NOTE] > > Direct API integrations require a signed agreement with Gravity. Send an email to [support@trygravity.com](mailto:support@trygravity.com) to request access. ```ts @gravity-ai/api (SDK) theme={null} import { Gravity } from '@gravity-ai/api'; const gravity = new Gravity({ production: true }); const { ads } = await gravity.getAds(req, messages, [ { placement: 'below_response', placement_id: 'main' } ]); ``` ```python gravity_sdk (SDK) theme={null} from gravity_sdk import Gravity gravity = Gravity(production=True) result = await gravity.get_ads(request, messages, [ {"placement": "below_response", "placement_id": "main"} ]) ads = result.ads ``` ```bash API (direct HTTP) theme={null} curl -X POST https://server.trygravity.com/api/v1/ad \ -H "Authorization: Bearer $GRAVITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [{"role":"user","content":"How do I deploy a serverless Postgres DB?"}], "sessionId": "sess_abc123", "placements": [{"placement":"below_response","placement_id":"main"}], "user": {"id": "user_789"}, "device": { "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...", "ip": "203.0.113.42" } }' ``` > [!WARNING] > > Send `device.ip` and `device.ua` on every ad request. Gravity uses them for fraud and bot detection, location, and the **Device** report. In the browser, `gravityContext()` collects them. Most AI platforms call the ad API from a server. In that case, your server must forward the `device` object from the client. If it does not, the request carries the IP address and User-Agent of your server, not of the end user. See [Device signals](#device-signals). ## What's in the request Want to compare ad earnings with your model costs? Add optional **`usage`** to your ad request. Send the input and output token counts from **one completed model call**, not totals for the conversation or user. See [Report model usage](#report-model-usage) below for timing and an example, or [Understand what your users earn you](/ai-platforms/user-earnings) for the dashboard and user tags. | Field | Required | Description | | - | - | - | | `messages` | Yes, by default | The recent conversation, as an array of `{role, content}`. The engine uses it to match ads to the conversation. The SDKs send the last 2 messages. | | `sessionId` | Yes | Your session ID. Gravity uses it for frequency caps and experiment groups. If the [Gravity pixel](/ai-platforms/pixel) is on the page, you do not have to send it. The pixel creates a session ID that starts with `gr_sess_` and ends after 30 minutes of inactivity. `gravityContext()` reads it from `window.gravityPixel.getSessionId()` (`@gravity-ai/api` 1.1.7 or later). A value that you send has priority. | | `placements` | Yes | An array of `{placement, placement_id}`, from 1 to 10 items. Each `placement_id` must be unique in the request. | | `user.id` | Yes in the JavaScript SDK, optional in the API | Your stable ID for the user. Gravity uses it for frequency caps, attribution, and identity links. In the JavaScript SDK, you send it as `user.userId` to `gravityContext()`, which throws an error if it is missing. The SDK sends it as `user.id`. For signed-out users, send a stable anonymous install or device ID, not a shared constant. Gravity leaves the SDK fallback `"anonymous"` out of per-user analytics, and an email alone is not a user ID. See [User IDs](/ai-platforms/user-earnings#send-a-user-id-on-every-request). | | `user.hashed_email` / `user.hashed_phone` | Optional | SHA-256 hashes of the user's email and phone number. See [Hash user data](#hash-user-data). | | `user.tags` | Optional | A flat object of up to 10 tags, for example `{ "plan": "free" }`, that group your users in your own analytics. Gravity never uses tags for ad targeting and never shares them with advertisers. See [Understand what your users earn you](/ai-platforms/user-earnings). | | `usage` | Optional | The usage of one model call: `{ model, inputTokens, cachedInputTokens, outputTokens }`. Input is the full prompt and context of that call. Cached input is part of the input, not extra tokens. Output is the reply of that call. Do not send running totals. The optional `cost` is the amount in USD that you paid for the same call. Gravity ignores malformed values. See [Report model usage](#report-model-usage). | | `device` | Recommended (`ip` and `ua`) | Device signals of the end user. Send `ua` and `ip` on every request. Other fields are optional. In the browser, `gravityContext()` collects them. A server must forward them. See [Device signals](#device-signals). | | `excludedTopics` | Optional | An array of topics to exclude, for example `["politics"]`. | | `relevancy` | Optional | The minimum relevancy score, from 0.0 to 1.0. The SDKs send `0.2` by default. See [Relevancy tradeoff](#relevancy-tradeoff). | | `testAd` | Optional | Default `false`. When `true`, the engine returns test ads with no billing and no metrics. The SDKs set it to the opposite of their `production` option. | | `alwaysReturn` | Optional | Default `false`. Only with `testAd: true`. The engine returns a test ad instead of `204 No Content`. | ## Report model usage **When a new user message triggers an ad request, send usage from the previous completed assistant reply in that same conversation.** The new model call hasn't finished yet. On the first turn, omit `usage`. Keep the provider's usage object when a reply finishes, then attach it to the next turn's ad request. Report that completed call only once. This keeps ad fetching parallel with the new model call; you never wait for its token counts. `inputTokens` is everything read by that previous call (system prompt, conversation history and other context); `outputTokens` is what it generated. Input can grow as a chat gets longer because earlier messages are sent to the model again, but you still report only the counts from that one call, not running totals. ```json theme={null} "usage": { "model": "gpt-4.1-mini", "prompt_tokens": 1830, "prompt_tokens_details": { "cached_tokens": 1024 }, "completion_tokens": 412 } ``` If your integration instead fetches ads **after** the model finishes, send that completed reply's usage on that request, not again on the next turn. Send usage once for the request, even with multiple placements; bid/render integrations send it on bid only. With next-turn reporting, the final reply's usage isn't reported if the user never sends another message. You can pass an OpenAI-compatible usage object directly with the model name: `usage: { model: completion.model, ...completion.usage }`. Snake\_case token names and nested cache-read counts (`input_tokens_details.cached_tokens` or `prompt_tokens_details.cached_tokens`) are accepted. OpenAI's input/prompt total already includes cached tokens: no manual summing is needed. You can also send `cachedInputTokens` (`cached_input_tokens`); it's a subset of total input. Malformed cache counts are ignored and accepted counts are bounded by input. Optional `cost` is the USD you paid for that same call and overrides the list-price estimate. See [the HTTP, JavaScript and Python examples](/ai-platforms/user-earnings#send-both), including the Anthropic cache-accounting caveat. ## Device signals The `device` object tells Gravity that a real device sent the request, and where the device is. Send `device.ip` and `device.ua` on every ad request. Without them, Gravity cannot do bot and fraud detection, and cannot connect the request to a device type and a location. | Field | Type | Required | Description | | - | - | - | - | | `ua` | `string` | Recommended | The User-Agent of the end user's browser or app, not of your server. Gravity uses it for fraud detection and the **Device** report (mobile, tablet, or desktop). | | `ip` | `string` | Recommended | The IP address of the end user. Gravity uses it for fraud detection and the **Geography** report. The SDK reads it from the incoming request headers. If you call the HTTP API directly, you must set it. | | `country` | `string` | No | A two-letter ISO country code. Gravity also gets the country from `ip`. | | `os` | `string` | No | The operating system, for example `iOS`, `Android`, or `Windows`. | | `ifa` | `string` | No | The mobile advertising ID (IDFA or GAID), for apps. | | `id` | `string` | No | Your stable ID for the device, for analytics and fraud detection. | The engine also accepts other fields, for example `timezone`, `locale`, `browser`, and `device_model`. ```json theme={null} "device": { "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15", "ip": "203.0.113.42", "country": "US", "os": "iOS" } ``` > [!NOTE] > > **Native apps and other clients.** If your iOS or Android app calls Gravity, send the real User-Agent of the app client (for example `CFNetwork/Darwin`, `okhttp`, or `ktor-client`) and the end user's IP address. Gravity identifies common native-app clients as mobile. For an IDE or a CLI, send the User-Agent of the client application. A call from your server must also forward `device.ip` and `device.ua` of the end user, so that Gravity sees the end user and not your server. ### Forward device signals from your server `gravityContext()` collects the `device` object on the client. Your client sends this context to your server, and your server sends it to Gravity. The SDK methods `getAds(req, ...)` and `get_ads(request, ...)` read `gravity_context` (with `device`) from the request body. They also add the end user's `ip` from the incoming request headers. If you build the ad request yourself, copy the `device` object from the client into the request body. ## Data quality Each of these signals helps Gravity match ads, report results, and connect conversions to impressions. Device signals are needed for fraud checks (see above). The rest are optional, but the better your data, the higher your eCPM tends to be. * **Conversation context.** Send the recent messages, not only the last one. * **Stable user ID.** A consistent `user.id` per user lets Gravity remove duplicate requests and apply frequency limits. * **Device and locale.** `gravityContext()` adds these in the browser. If you call the API directly, forward the end user's `device.ua` and `device.ip` (see [Device signals](#device-signals)). * **Hashed email or phone.** Lets Gravity credit you for view-through conversions: a purchase on the advertiser's site after the user saw your ad but did not click it. Respect your users' privacy choices. Pass their consent signals in the `consent` object (see [Consent](/engine/contextual-ads)), and only send hashed identifiers you are allowed to share. ### Hash user data Never send a raw email or phone number. Use `hashPii()` in the browser to hash the values (SHA-256 of the normalized value) before the request leaves the client: ```ts theme={null} import { gravityContext, hashPii } from '@gravity-ai/api'; // Hash on the client, before the request leaves the browser. const hashed = await hashPii({ email: user.email, phone: user.phone }); // hashed = { hashed_email: '...', hashed_phone: '...' } const gravity_context = gravityContext({ sessionId: chatSession.id, user: { userId: user.id, ...hashed }, }); ``` The Python SDK reads `user` from `gravity_context.user` in the request body. Hash the values on the client, as in the example above. If you call the HTTP API directly and make the hashes yourself, use the same normalization: * **email:** `sha256(email.strip().lower())` * **phone:** `sha256(re.sub(r"\D+", "", phone))` (digits only) Send the email hash as `user.hashed_email` and the phone hash as `user.hashed_phone`, as hex strings. > [!NOTE] > > The engine also accepts the older `user.email_hash` field, and raw `user.email` and `user.phone` fields, for backward compatibility. The engine hashes raw values when it receives them. New integrations must send `user.hashed_email`. ## What's in the response When an ad matches, the endpoint returns HTTP `200` with a **JSON array** of ad objects: ```json theme={null} [ { "adText": "Serverless Postgres that scales to zero. Start free.", "title": "Neon Serverless Postgres", "brandName": "Neon", "cta": "Try Neon Free", "url": "https://neon.tech", "favicon": "https://icons.duckduckgo.com/ip3/neon.tech.ico", "clickUrl": "https://api.trygravity.ai/track/click?p=...", "impUrl": "https://api.trygravity.ai/ack?p=...", "placement": "below_response", "placement_id": "main" } ] ``` The SDKs put this array in a result object: `{ ads, status, elapsed }` in JavaScript, and `AdResult(ads, status, elapsed_ms, ...)` in Python. The result object is part of the SDK only. It is not in the HTTP response. Use `clickUrl` for ad links, not `url`. `clickUrl` records the click and sends a `302` redirect to the landing page. Request `impUrl` when the ad becomes visible. In the React SDK, `GravityAd` does this for you. When no ad matches, the endpoint returns **HTTP `204 No Content` with an empty body**. It also returns `204` when it filters the request as a bot, when the request times out, and on an internal error. Hide the ad slot when there is no ad. | Status | Meaning | | - | - | | `200` | One or more ads. | | `204` | No ad. The body is empty. | | `400` | A field that your account requires is missing, for example `messages`. | | `401` | The API key is not valid. | | `403` | Gravity did not approve your platform yet, and `testAd` is not `true`. The body has `"error": "publisher_not_approved"`. | | `422` | The request body is not valid, for example no `sessionId` or more than 10 placements. | ## Framework examples ### Next.js (App Router) In a route handler, `req` is a Web `Request`. Parse the body, then give the SDK an object with `body` and `headers`. ```ts theme={null} // app/api/chat/route.ts import { Gravity } from '@gravity-ai/api'; const gravity = new Gravity({ production: process.env.NODE_ENV === 'production' }); export async function POST(req: Request) { const body = await req.json(); const { messages } = body; const adPromise = gravity.getAds( { body, headers: Object.fromEntries(req.headers) }, messages, [{ placement: 'below_response', placement_id: 'main' }], ); const stream = new ReadableStream({ async start(controller) { for await (const chunk of callLLM(messages)) { controller.enqueue(`data: ${JSON.stringify({ type: 'chunk', content: chunk })}\n\n`); } const { ads } = await adPromise; controller.enqueue(`data: ${JSON.stringify({ type: 'done', ads })}\n\n`); controller.close(); } }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }); } ``` ### FastAPI ```python theme={null} import asyncio, json from fastapi import Request from fastapi.responses import StreamingResponse from gravity_sdk import Gravity gravity = Gravity(production=True) @app.post("/api/chat") async def chat(request: Request): body = await request.json() messages = body["messages"] ad_task = asyncio.create_task( gravity.get_ads(request, messages, [ {"placement": "below_response", "placement_id": "main"} ]) ) async def event_stream(): async for token in stream_your_llm(messages): yield f"data: {json.dumps({'type': 'chunk', 'content': token})}\n\n" ad_result = await ad_task ads = [a.to_dict() for a in ad_result.ads] yield f"data: {json.dumps({'type': 'done', 'ads': ads})}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") ``` ## Multiple placements You can request more than one placement in one call. All placements use the same auction: ```ts theme={null} const { ads } = await gravity.getAds(req, messages, [ { placement: 'below_response', placement_id: 'main' }, { placement: 'right_response', placement_id: 'sidebar' }, { placement: 'inline_response', placement_id: 'inline-1' }, ]); const mainAd = ads.find((ad) => ad.placement_id === 'main'); const sidebarAd = ads.find((ad) => ad.placement_id === 'sidebar'); ``` A slot can have no ad. Use the `placement_id` field of each ad to find its slot, and show a slot only when it has an ad. ## Tune the request ### Relevancy tradeoff `relevancy` is a score from 0.0 to 1.0. The SDK default is `0.2`. This setting has a large effect on your results, so make sure that you understand the tradeoff: | Setting | Result | When to use it | | - | - | - | | Lower (for example `0.1`) | Ads match the conversation less closely. The **fill rate** (the part of requests that get an ad) is higher. Some ads can seem less related to the conversation. | Pages where you want an ad on each turn. New integrations where you want to see traffic. | | Higher (for example `0.4` to `0.5`) | Ads match the conversation more closely. The fill rate is lower, and many turns get no ad. The ads that show are very relevant. | Premium surfaces where no ad is better than a weak match. | Gravity recommends `0.2` for most integrations. Start there, and change it if your results for each slot show a need. Gravity applies a minimum relevancy for the full network. A value below this minimum has no effect. Gravity can also set a relevancy value for your account. When it does, that value replaces the value in your request. ```ts theme={null} const gravity = new Gravity({ production: true, relevancy: 0.35 }); ``` ### Timeout The JavaScript SDK stops the request after 4 seconds (`timeoutMs`). The engine also stops its work after 4 seconds and returns `204`. The Python SDK stops the request after 3 seconds (`timeout`, in seconds). Use a higher value if your stack can wait longer. Use a lower value if your latency limits are strict. ```ts theme={null} const gravity = new Gravity({ production: true, timeoutMs: 5000 }); ``` ### Excluded topics Exclude topics for one request: ```ts theme={null} await gravity.getAds(req, messages, placements, { excludedTopics: ['politics', 'religion'] }); ``` ## Next steps > **[Show ads](/ai-platforms/show-ads)** > > Show the ads in your UI. > **[API reference](/engine/contextual-ads)** > > All HTTP parameters. # Show ads Source: https://docs.trygravity.com/ai-platforms/show-ads Show Gravity ads in your UI with the React component, or with your own component. This page tells you how to show the ads that you get from [Request ads](/ai-platforms/request-ads). Use the `GravityAd` React component, or write your own component. ## Before you start * You get ads from your server with `gravity.getAds()` or `gravity.get_ads()`. * For React, you installed `@gravity-ai/react`: ```bash theme={null} npm install @gravity-ai/react ``` ## Show an ad with React ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; {ads[0] && } ``` `GravityAd` does these tasks for you: * It shows the ad design that Gravity selects for the ad. * It shows the "Sponsored" label. To change the text, use the `labelText` prop. * It requests `impUrl` one time, when at least half of the ad is in the viewport. * It opens `clickUrl` when the user clicks the ad. * It shows the `fallback` prop, or nothing, when `ad` is `null`. ### Lead form ads Some ads are lead forms. A **lead form** is an ad that collects the user's contact details for the advertiser. To let `GravityAd` send lead form submissions, give it the API key and the session ID of the ad request: ```tsx theme={null} ``` > [!NOTE] > > The `apiKey` prop is visible in the browser. `GravityAd` uses it only to send lead form submissions. Send the key from your server with the ad response. Do not read a server-only environment variable in client code. The `sessionId` must be the same as the session ID of the ad request. ## Show more than one placement Each ad has a `placement_id` field. Use it to put each ad in the correct slot. Show a slot only when it has an ad: ```tsx theme={null} const mainAd = ads.find((ad) => ad.placement_id === 'main'); const sidebarAd = ads.find((ad) => ad.placement_id === 'sidebar'); {mainAd && } {sidebarAd && } ``` ## Show an ad with your own component Use the official component when you can. If you must write your own component, do these steps: 1. On the server, set `supportsSpec: false` and `enableFrames: false` in the `Gravity` options. Then Gravity sends only ads that your component can show. 2. Show a clear ad label, for example "Sponsored". 3. Use `ad.clickUrl` for the link. Do not use the landing page URL (`ad.url`). 4. Request `ad.impUrl` one time, when the ad becomes visible. ```ts theme={null} const gravity = new Gravity({ production: true, supportsSpec: false, enableFrames: false, }); ``` ```ts theme={null} // When the ad becomes visible: new Image().src = ad.impUrl; ``` For request setup, see the [JavaScript and React SDK guide](/sdks/javascript). ## Troubleshoot > **The ad does not show** > > Make sure that `ads` has an item for the slot. No ad for a slot is normal. Also make sure that your server sends the `ads` array to the client. > **Impressions do not count** > > `GravityAd` sends the impression only when at least half of the ad is in the viewport. Scroll the ad into view. If you use your own component, make sure that it requests `ad.impUrl`. > **Clicks do not count** > > Make sure that the link opens `ad.clickUrl`, not `ad.url`. # Impression & click tracking proxy Source: https://docs.trygravity.com/ai-platforms/tracking-proxy Send impression and click tracking through your own domain, so that you can log each event on your server. This page tells you how to send Gravity impression and click tracking through your own domain. A **tracking proxy** is a route on your server that receives the tracking requests, logs them, and forwards them to Gravity. When Gravity serves an ad, the response has two tracking URLs: * **`impUrl`**: the client requests it when the ad becomes visible (an impression). * **`clickUrl`**: the client opens it when the user clicks the ad. By default, these URLs use `api.trygravity.ai`. With a tracking proxy, they use your domain. Advertisers use the same pattern for [first-party conversion tracking](/advertisers/server-conversions). ## Why use a tracking proxy * **First-party data**: impressions and clicks come to your domain. You can log them in your analytics, data warehouse, or billing system. * **Verification**: you can compare your impression and click counts with the counts that Gravity reports. * **Latency data**: you can monitor the tracking requests from your own infrastructure. * **No SDK changes**: the SDK requests the URL that is in `impUrl` and `clickUrl`. You change these URLs on your server before you send the ad to your client. ## How it works **1. Set up a reverse proxy** Add a route on your server (for example `/gravity/`) that forwards requests to `api.trygravity.com`. **2. Rewrite the tracking URLs** On your server, after you call `gravity.getAds()`, replace the Gravity domain in `impUrl` and `clickUrl` with your proxy URL. **3. Log the events** Your proxy receives each impression and click request. Log the data that you need, then forward the request to Gravity. ```mermaid theme={null} sequenceDiagram participant Browser as Browser (SDK) participant Proxy as Your Server participant Gravity as Gravity API Browser->>Proxy: GET /gravity/ack?p=... Note over Proxy: Log impression Proxy->>Gravity: GET /ack?p=... Gravity-->>Proxy: 200 OK Proxy-->>Browser: 200 OK Browser->>Proxy: GET /gravity/track/click?p=... Note over Proxy: Log click Proxy->>Gravity: GET /track/click?p=... Gravity-->>Proxy: 302 → landing page Proxy-->>Browser: 302 → landing page ``` ## 1. Set up the reverse proxy > [!WARNING] > > Do not cache tracking responses, and do not follow the click redirect on your server. Each request must go to Gravity, and the `302` must go back to the browser. See [Rules for the proxy](#rules-for-the-proxy). ### Nginx ```nginx theme={null} location /gravity/ { proxy_pass https://api.trygravity.com/; proxy_set_header Host api.trygravity.com; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_ssl_server_name on; # Optional: log impressions and clicks access_log /var/log/nginx/gravity-tracking.log; } ``` ### Next.js (middleware or API route) ```ts theme={null} // app/api/gravity/[...path]/route.ts import { NextRequest, NextResponse } from 'next/server'; const GRAVITY_API = 'https://api.trygravity.com'; export async function GET( req: NextRequest, { params }: { params: Promise<{ path: string[] }> } ) { const { path } = await params; const target = `${GRAVITY_API}/${path.join('/')}?${req.nextUrl.searchParams}`; // Log the event on your side console.log(`[gravity] ${path[0]}`, { timestamp: new Date().toISOString(), ip: req.headers.get('x-forwarded-for'), ua: req.headers.get('user-agent'), }); const res = await fetch(target, { redirect: 'manual', headers: { 'X-Forwarded-For': req.headers.get('x-forwarded-for') || '', 'User-Agent': req.headers.get('user-agent') || '', }, }); return new NextResponse(res.body, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') || 'application/json', ...(res.headers.get('location') && { Location: res.headers.get('location')! }), }, }); } ``` ### Express ```ts theme={null} import { createProxyMiddleware } from 'http-proxy-middleware'; app.use('/gravity', createProxyMiddleware({ target: 'https://api.trygravity.com', changeOrigin: true, pathRewrite: { '^/gravity': '' }, on: { proxyReq: (proxyReq, req) => { // Log the event on your side console.log(`[gravity] ${req.method} ${req.url}`, { timestamp: new Date().toISOString(), ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress, }); }, }, })); ``` ### Python (FastAPI) ```python theme={null} import httpx from fastapi import APIRouter, Request from fastapi.responses import Response router = APIRouter() GRAVITY_API = "https://api.trygravity.com" @router.api_route("/gravity/{path:path}", methods=["GET", "POST"]) async def gravity_proxy(request: Request, path: str): # Log the event on your side print(f"[gravity] {request.method} /{path}") url = f"{GRAVITY_API}/{path}?{request.url.query}" async with httpx.AsyncClient() as client: resp = await client.request( request.method, url, headers={ "X-Forwarded-For": request.headers.get("x-forwarded-for", ""), "User-Agent": request.headers.get("user-agent", ""), }, content=await request.body(), ) return Response( content=resp.content, status_code=resp.status_code, headers=dict(resp.headers), ) ``` ## 2. Rewrite the tracking URLs After you call `gravity.getAds()` on your server, replace the Gravity domain in each ad with your proxy URL. Then send the changed ads to the client. ### Node / TypeScript ```ts theme={null} const { ads } = await gravity.getAds(req, messages, placements); const YOUR_PROXY_BASE = 'https://yourapp.com/gravity'; const rewrittenAds = ads.map(ad => ({ ...ad, impUrl: ad.impUrl?.replace(/^https:\/\/api\.trygravity\.(com|ai)/, YOUR_PROXY_BASE), clickUrl: ad.clickUrl?.replace(/^https:\/\/api\.trygravity\.(com|ai)/, YOUR_PROXY_BASE), })); // Send rewrittenAds to the client instead of ads ``` ### Python `get_ads()` returns an `AdResult`. Its `ads` items are frozen dataclasses, so make changed copies with `dataclasses.replace()`. ```python theme={null} import re from dataclasses import replace result = await gravity.get_ads(request, messages, placements) PROXY_BASE = "https://yourapp.com/gravity" GRAVITY_HOST = r"^https://api\.trygravity\.(com|ai)" def proxied(url): return re.sub(GRAVITY_HOST, PROXY_BASE, url) if url else url rewritten_ads = [ replace(ad, imp_url=proxied(ad.imp_url), click_url=proxied(ad.click_url)) for ad in result.ads ] # Send [ad.to_dict() for ad in rewritten_ads] to the client ``` > [!NOTE] > > The `?p=` token in each URL is encrypted, and you cannot change it. You change only the domain at the start of the URL. Billing and attribution at Gravity continue to work. ## 3. Log impressions and clicks Your proxy receives each tracking request. This table shows what each path tells you: | Path | Event | When the request occurs | | - | - | - | | `/ack` or `/track/imp` | Impression | The ad became visible. `GravityAd` sends it when at least half of the ad is in the viewport. | | `/track/click` | Click | The user clicked the ad. | | `/c/{code}` | Click | The user clicked a short click link. Some surfaces that show links as plain text use short links. | The `?p=` query parameter is an encrypted payload. It contains the click ID, the campaign ID, the publisher ID, the ad ID, and the price. You cannot decrypt it, because Gravity encrypts it with its own key. But you can count requests, log timestamps, and connect the requests to your session and user data from the request headers. ### Example: structured logs ```ts theme={null} // Inside your proxy handler const event = { type: path.includes('click') ? 'click' : 'impression', timestamp: new Date().toISOString(), ip: req.headers['x-forwarded-for'], userAgent: req.headers['user-agent'], referer: req.headers['referer'], }; // Send to your analytics pipeline await yourAnalytics.track('gravity_ad_event', event); ``` ## Alternative: client-side callbacks If you do not need a full proxy, use the `onImpression` and `onClick` props of `GravityAd`. They run in the browser: ```tsx theme={null} { fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ event: 'impression', campaignId: ad.campaignId, placement: ad.placement, timestamp: Date.now(), }), }); }} onClick={() => { fetch('/api/analytics', { method: 'POST', body: JSON.stringify({ event: 'click', campaignId: ad.campaignId, placement: ad.placement, timestamp: Date.now(), }), }); }} /> ``` Callbacks are easier to add, but they run in the browser. A proxy gives you data on your server, and it works with all ways to show ads, not only the React SDK. ## Rules for the proxy * **Forward `X-Forwarded-For`.** Gravity uses the end user's IP address for location and fraud detection. Without it, all impressions seem to come from the IP address of your server. * **Forward `User-Agent`.** Gravity uses it to find the device type and to filter bots. * **Do not cache.** Each impression and click request must go to Gravity. Gravity removes duplicate impressions, but a cached response does not reach Gravity, and Gravity does not record the billing event. * **Do not follow click redirects.** `/track/click` returns a `302` redirect to the advertiser's landing page. Your proxy must return this redirect to the browser. ## Index Gravity Index sponsored picks use a different click endpoint. Each recommendation has a short link on `index.trygravity.com`. You can use the same proxy pattern, with these differences: * **Proxy target**: `https://index.trygravity.com/go/{code}`. Forward the path as it is. The `code` is the click ID. * **Return the 302 as it is**: `/go/{code}` returns a `302` to the provider's landing page. Return this redirect to the user's browser. Do not follow it on your server. If your server follows it, the click comes from your server's IP address, and the user does not take the attribution parameters to the provider. * **Only the first click on a link counts**: a second click on the same link also redirects, but Gravity does not count it again. * **Attribution needs a conversion at a CPA provider**: a click alone does not earn money. Gravity records a payout when the user converts (for example, signs up) at a provider with an active CPA campaign. A click to a provider without a CPA campaign is only a redirect. * **Send `hashed_email` in searches**: when you have the user's email, send its SHA-256 hash (of the normalized email) in the [search request](/gravity-index/search). Gravity connects conversions to the search that gave the click, and `hashed_email` makes this match reliable. ## Next steps > **[Show ads](/ai-platforms/show-ads)** > > How the SDK tracks impressions and clicks. > **[Payouts](/ai-platforms/payouts)** > > How your tracked impressions become revenue. # Understand what your users earn you Source: https://docs.trygravity.com/ai-platforms/user-earnings Send user tags and model usage on your ad requests to see earnings and model cost per user group. Two optional fields on the ad request let your dashboard answer a question that totals can't: **which users earn more than they cost?** Send `user.tags` to group your users, and `usage` to report the model response behind each ad request. Both are optional, and neither changes which ads you get. In **Audience → Users** you'll see, by country, tag and model: * earnings per 1,000 messages * model cost per 1,000 messages * **Profitability:** ad earnings ÷ model cost. 100% is break-even; above 100% is green and below is red. This isn't a conventional profit margin. In the Users tab, click **+ Filter**, choose a dimension (country, model, messages per day or new vs returning), then select one or more values. For tags, choose **Tags → key → values**. Only active filters appear as pills. The breakdown starts collapsed, grouped by **Country**, then **Model**: expand a country to compare each model's earnings, model cost and Profitability, and see which models you can afford to serve there. **Earnings/user** compares messages-per-day cohorts. Groups under 50 users aren't shown. > [!NOTE] > > Per-user analytics only count requests that carry a user ID. See [Send a user ID on every request](#send-a-user-id-on-every-request). ## Group users with tags `user.tags` is a flat object of up to 10 keys: ```json theme={null} "user": { "id": "user_789", "tags": { "plan": "free", "utm_source": "tiktok" } } ``` Tags are used only to group users in your own analytics. They're never used for ad targeting and never shared with advertisers. Groups with fewer than 50 users aren't shown. **Start with `plan`.** Send `plan` as `free` or `paid` first. It shows what your free users earn, which is the quickest way to see whether ads pay for their model cost. | Rule | Behavior | | - | - | | Keys | Lowercased. Must be lowercase letters, digits and underscores, 1 to 32 characters. Other keys are dropped. | | Values | Strings, numbers or booleans. Stored as strings and trimmed to 64 characters. Empty values, nested objects and lists are dropped. | | More than 10 valid keys | The first 10 in sorted order are kept. | | Updates | Resend the complete tag map on every request. The latest pending map per UTC day wins; omitted keys from earlier requests that day aren't preserved. Persisted maps merge across completed days, with newer values winning per key. | ## Report model usage `usage` sits at the top level of the ad request. **When you request an ad on a new user message, send the previous completed assistant reply's usage from that same conversation.** The new model call hasn't finished yet. Omit `usage` on the first turn; save each completed reply's provider usage for the next turn's ad request and report it only once. Do not send running totals for the conversation, session or user. * **Input tokens:** everything the model read for that one call, including the system prompt, conversation history and other context sent with it. This is not just the latest user message. * **Output tokens:** the tokens the model generated in that one reply. Use the usage object returned by your model provider for that call. For example, if it reports 1,830 input tokens (including 1,024 cached tokens) and 412 output tokens, send those numbers unchanged: ```json theme={null} "usage": { "model": "gpt-4.1-mini", "inputTokens": 1830, "cachedInputTokens": 1024, "outputTokens": 412 } ``` | Field | Type | Description | | - | - | - | | `model` | `string` | The model ID as the provider names it. | | `inputTokens` | `integer` | Tokens read by this one model call, including its full prompt/context. Non-negative; not a cumulative total. | | `cachedInputTokens` | `integer` | Optional cache-read tokens, a subset of `inputTokens`, not extra input. `cached_input_tokens` is also accepted. | | `outputTokens` | `integer` | Tokens generated by this one model call. Non-negative; not a cumulative total. | | `cost` | `number` | Optional. The USD you paid for this model call. See [Make the cost figure exact](#make-the-cost-figure-exact). | * **It never fails a request.** Malformed values are ignored. * **Provider keys work as-is.** `input_tokens` / `output_tokens` and `prompt_tokens` / `completion_tokens` are also accepted, so you can spread a provider's usage object straight in. * **Send `model` even without token counts.** Earnings can still be broken down by model. * **Don't wait for the new model call.** Request the ad in parallel, carrying the previous completed reply's usage. If the conversation ends, that final reply's usage won't be reported through a later ad request. * **If you instead request ads after the reply finishes,** send that completed reply's usage then, not again on the next turn. * **One model response per ad request, not per placement.** If you use a bid/render integration, send `usage` on the bid request, not again on render. ### Make the cost figure exact `cost` is optional and only makes the cost figure exact. It's the USD you paid for the model call behind this reply, from 0 to 100: ```json theme={null} "usage": { "model": "openai/gpt-4.1-mini", "inputTokens": 1830, "outputTokens": 412, "cost": 0.00112 } ``` * **When `cost` is present,** the dashboard uses it instead of the list-price estimate. * **When it's absent,** cost is estimated from the model maker's public list price. Gravity doesn't need your actual costs or discounts. * **`0` means a free call,** such as a self-hosted model. * **Invalid values are ignored** without failing the request. `cost_usd` is accepted as an alias. OpenRouter already returns the cost as `usage.cost`, so `usage: { model: res.model, ...res.usage }` carries it through with no extra code. ## Send both In these examples, `completion` is the **previous completed model response in this conversation**, saved when that reply finished. On the first turn, leave out `usage` rather than sending zeros. ```bash API (direct HTTP) theme={null} curl -X POST https://server.trygravity.com/api/v1/ad \ -H "Authorization: Bearer $GRAVITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [{"role":"user","content":"How do I deploy a serverless Postgres DB?"}], "sessionId": "sess_abc123", "placements": [{"placement":"below_response","placement_id":"main"}], "user": { "id": "user_789", "tags": {"plan": "free", "utm_source": "tiktok"} }, "usage": { "model": "gpt-4.1-mini", "input_tokens": 1830, "input_tokens_details": {"cached_tokens": 1024}, "output_tokens": 412 }, "device": { "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...", "ip": "203.0.113.42" } }' ``` ```ts @gravity-ai/api (SDK) theme={null} import { gravityContext, Gravity } from '@gravity-ai/api'; // Client: add tags next to the user ID. const gravity_context = gravityContext({ sessionId: chatSession.id, user: { userId: currentUser.id, tags: { plan: currentUser.plan } }, }); // Server: pass the provider's usage object with the model name. const gravity = new Gravity({ production: true }); const { ads } = await gravity.getAds( req, messages, [{ placement: 'below_response', placement_id: 'main' }], { usage: { model: completion.model, ...completion.usage } }, ); ``` ```python gravity_sdk (SDK) theme={null} from gravity_sdk import Gravity gravity = Gravity(production=True) # Tags travel in gravity_context.user, posted by your client: # { "gravity_context": { "user": { "userId": "u123", "tags": { "plan": "free" } } } } result = await gravity.get_ads( request, messages, [{"placement": "below_response", "placement_id": "main"}], usage={"model": completion.model, **completion.usage.model_dump()}, ) ``` The spreads above preserve OpenAI-compatible cache-read details: Responses returns `input_tokens_details.cached_tokens`, and Chat Completions returns `prompt_tokens_details.cached_tokens`. Both are accepted directly, as in the HTTP example. OpenAI's input/prompt totals already include cached tokens. Don't add or subtract them yourself: the list-price estimate uses the published cache-read rate for the cached subset and the normal input rate for the rest. When no cache-read rate is known, cached tokens use the ordinary input rate. Malformed cache counts, including counts greater than total input, are ignored. A valid `usage.cost`, including `0`, still overrides the estimate. This cache format follows OpenAI semantics, not full Anthropic cache accounting. Anthropic's `input_tokens` excludes cache reads and writes, so spreading its raw usage doesn't provide a full cache-aware estimate. Send the actual `cost` when you know it. ## Send a user ID on every request Per-user analytics, including everything on this page, only count requests that carry a user ID other than the literal `"anonymous"`. Requests without one still earn, but they drop out of the Users views. If some of your users can use the app signed out, send a stable anonymous ID for them as `user.id` (`user.userId` in `gravityContext()`), such as an install ID or device ID that you generate once and keep. Don't use one shared `"anonymous"` value. Email alone isn't used as a user key, so a hashed email doesn't replace `user.id`. ## Next > **[Request ads](/ai-platforms/request-ads)** > > Every field on the ad request. > **[Payouts & analytics](/ai-platforms/payouts)** > > How revenue and the dashboard work. # Ad groups and ads Source: https://docs.trygravity.com/api-reference/advertiser/ad-groups Create and manage the ad groups and ads of a campaign. A campaign contains ad groups, and an ad group contains ads. An ad group has one of two types: * **`generative`** (default): Gravity makes the ads for the group from your `context` and `ad_prompt`. If you send authored `ads`, the response is `422`. * **`manual`**: you write the ads in the `ads` array or with the ads endpoints below. You cannot change generated ads. You can change or archive only manual ads. > [!WARNING] > > Users see the `brand_name` of an ad group. It is the brand next to the ad. It is also the title of the ad when the ad has no `headline`. If you create groups for your own users, put the brand of the end advertiser in `brand_name`. Do not use your internal account or workspace label (for example `"Jane Doe on YourApp"`), because Gravity shows that text to readers. A change to `brand_name` does not change the headlines that Gravity already generated for the group. To change what Gravity serves, create a new group, or set a `headline` on a manual ad. These endpoints use the same code and ownership checks as the Gravity dashboard. They apply only to the account of your API key. ## GET `/advertiser-dashboard/ad-groups` List your ad groups. Each group contains its ads, up to 100 for each group. `ads_total` gives the full count. Without `campaign_id`, the list has at most 2,000 ad groups. **campaign\_id** `string` Show only one campaign. **include\_archived** `boolean` (default: false) Include archived groups. ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/ad-groups?campaign_id=camp-456" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} [ { "id": "adg-789", "campaign_id": "camp-456", "name": "Summer Sale (Generative)", "group_type": "generative", "status": "active", "context": "Summer footwear sale, focus on running shoes", "landing_page": null, "daily_budget": null, "auto_optimize_budget": true, "max_cpc": null, "allowed_countries": null, "allowed_devices": null, "sort_order": 0, "created_at": "2026-07-23 00:00:00", "updated_at": "2026-07-23 00:00:00", "ads": [], "ads_total": 0 } ] ``` ## POST `/advertiser-dashboard/ad-groups` Create an ad group in one of your campaigns. **campaign\_id** `string` *required* The campaign for the group. Your account must own it. **name** `string` *required* The ad group name, at most 500 characters. **group\_type** `string` (default: generative) `generative` or `manual`. **status** `string` (default: active) `active`, `paused`, or `archived`. **context** `string` The target context. Gravity uses it to match conversations, and to make ads for generative groups. **negative\_context** `string` The conversations to avoid. **ad\_prompt** `string` A prompt that guides the ad generation (generative groups). **landing\_page** `string` The default landing page for the ads of the group. **brand\_name** `string` The brand that you advertise. Users see it. Gravity uses it as the title of an ad that has no `headline`. Send the product or company that you advertise (`"Acme"`). Do not send an internal account, workspace, or person label (`"Jane Doe on YourApp"`). If you do not send it, Gravity uses the host name of `landing_page`. At most 500 characters. **image\_url** `string` The default image for the ads of the group, for example the brand logo or a product image. An ad uses it when the ad has no `image_url`. A generated ad copies the group image when Gravity generates the ad. So a later change to the group `image_url` does not replace an image that is already on a generated ad. The URL must point to a public PNG, JPEG, GIF, or WebP file (not a web page) of 10 MB or less. Some delivery networks need an image on each ad. See [Ad images](#ad-images). **daily\_budget** `integer` Legacy stored field. Set the delivery budget on the campaign. **max\_cpc** `number` The maximum cost per click for the group, in dollars. It applies on a click-billed (`cpc`) campaign. Gravity ignores it on an impression-billed campaign. Gravity reads `0` as no value. **allowed\_countries** `array` The countries to target for this group. **allowed\_devices** `array` The platforms to target for this group. **ads** `array` Authored ads (manual groups only). Each ad accepts `name`, `headline` (at most 80 characters), `copy` (at most 500 characters), `cta` (at most 80 characters), `description`, `landing_page`, `image_url`, and `status`. The response is `201` with the new group and its ads, in the same shape as the list response. If you send authored `ads` to a generative group, the response is `422`. ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/ad-groups \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "campaign_id": "camp-456", "name": "Trail Runners (Manual)", "group_type": "manual", "context": "Trail running gear", "brand_name": "Acme", "ads": [ { "headline": "Grip Every Trail", "copy": "Trail shoes built for mud, rock, and rain.", "cta": "Shop Trail", "landing_page": "https://acme.com/trail" } ] }' ``` ### Ad images Some delivery networks need an image on each ad. Gravity uses the first image of this list that has a value: 1. The `image_url` of the ad. An authored ad gets it from your request. A generated ad copies the `image_url` of the group when Gravity generates the ad. 2. The current `image_url` of the ad group. 3. The custom favicon of the campaign. 4. A favicon that Gravity finds for the domain of the landing page. This lookup fails for many domains, so do not depend on it. If no image is available, Gravity does not submit the ad. The campaign shows `ad_image_required` (and `ad_sync_failed`) in `delivery_issues`. The `network_review.reason` of the ad is: "The delivery network requires an image for this ad. Set image\_url on the ad or its ad group to resubmit it." To fix it, do one of these steps: * Set `image_url` on the ad group with `PATCH /advertiser-dashboard/ad-groups/{ad_group_id}`. * Set `image_url` on an authored ad with `PATCH /advertiser-dashboard/ads/{ad_id}`. Gravity then submits the failed ads again. You cannot change a generated ad directly. A generated ad with no image of its own uses the new `image_url` of the group. ## GET `/advertiser-dashboard/ad-groups/{ad_group_id}` Get one ad group with its ads. If the ad group is not in your account, the response is `404`. ## PATCH `/advertiser-dashboard/ad-groups/{ad_group_id}` Change the fields of an ad group. Send only the fields that you want to change. You cannot change a `manual` group to `generative` while it has manual ads that are not archived (`422`). When you set or change `image_url`, Gravity submits the failed ads of the group again. ```bash Request theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/ad-groups/adg-789 \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "paused"}' ``` ## DELETE `/advertiser-dashboard/ad-groups/{ad_group_id}` Archive an ad group and its ads. The response is `204`. ## POST `/advertiser-dashboard/ad-groups/{ad_group_id}/ads` Write an ad in a **manual** group. The response is `201` with the new ad. On a generative group, the response is `422`. ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/ad-groups/adg-790/ads \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "headline": "50% Off Running Shoes", "copy": "Summer sale on all running shoes.", "cta": "Shop Now", "landing_page": "https://acme.com/summer" }' ``` ```json Response 201 theme={null} { "id": "ad-901", "ad_group_id": "adg-790", "campaign_id": "camp-456", "name": null, "headline": "50% Off Running Shoes", "copy": "Summer sale on all running shoes.", "cta": "Shop Now", "description": null, "landing_page": "https://acme.com/summer", "image_url": null, "source": "manual", "status": "active", "review_status": "pending", "created_at": "2026-07-23 00:00:00", "updated_at": "2026-07-23 00:00:00" } ``` ## GET `/advertiser-dashboard/ads/{ad_id}` Get one ad. If the ad is not in your account, the response is `404`. ## PATCH `/advertiser-dashboard/ads/{ad_id}` Change a manual ad (`headline`, `copy`, `cta`, `landing_page`, `status`, and the other ad fields). You cannot change generated ads. For a generated ad, the response is `422`. ## DELETE `/advertiser-dashboard/ads/{ad_id}` Archive a manual ad. The response is `204`. For a generated ad, the response is `422`. # Campaigns Source: https://docs.trygravity.com/api-reference/advertiser/campaigns Create, change, pause, and archive campaigns. Use these endpoints to create, change, pause, and archive campaigns with your API key. > [!WARNING] > > These endpoints change your live account. An active campaign can spend money. To test, create the campaign with `"status": "draft"` or `"status": "paused"`. ## POST `/advertiser-dashboard/campaigns` Create a campaign with ad groups (recommended), or with legacy ad units. The response is `201` with the new campaign. **name** `string` *required* The campaign name, at most 255 characters. **landing\_page** `string` *required* The destination URL of the campaign. **daily\_budget** `number` The average daily budget in dollars. It is necessary, except on `target_cpa` campaigns and total budget campaigns. Spend on one day (UTC) can reach 110% of the daily budget. Then the campaign stops serving until the next day. **budget\_type** `string` (default: daily) `daily` or `total`. A total budget needs `total_budget`, `end_time`, and OpenAI-only delivery (`network_mode: "openai"`). **total\_budget** `number` The maximum spend in dollars for the full schedule of a total budget campaign. Do not send `daily_budget` in this mode. **monthly\_budget** `number` A spend limit for each calendar month. Only `target_cpa` campaigns accept it. **status** `string` (default: active) The campaign status: `draft`, `active`, or `paused`. **campaign\_objective** `string` (default: clicks) The campaign objective: `clicks`, `reach`, or `conversions`. **billing\_model** `string` (default: cpm) Use `cpm` (pay per 1,000 impressions). Gravity does not offer click billing: the field still accepts `cpc` for older campaigns, but don't use it for new ones. You cannot change the value after the campaign launches on OpenAI. **bid\_strategy** `string` (default: lowest\_cost) The bid strategy: `lowest_cost` or `target_cpa`. **is\_active** `boolean` (default: true) Tells if the campaign is active. **start\_time** `string` The start time (ISO 8601). **end\_time** `string` The end time (ISO 8601). **click\_goal** `integer` A click goal. **max\_cpc** `number` Legacy. A fallback bid for the whole campaign. It applies to click-billed ad groups that have no `max_cpc`. Set a bid on each ad group instead. This field does not set the billing model. Gravity reads `0` as no value. **impression\_goal** `integer` An impression goal. **max\_cpm** `number` The maximum CPM. **conversion\_type** `string` The conversion event that the campaign optimizes and reports on. Use a preset (for example `purchase`) or a custom event name that you send with the pixel or CAPI. At most 100 characters. **max\_cpa** `number` The maximum CPA. **demographics** `object` The demographics settings. **utm\_params** `array` UTM parameters as `{ "key": "...", "value": "..." }` pairs. **allowed\_countries** `array` The countries to target. **geo\_targets** `array` Location IDs of countries, regions, and DMAs, at most 500. Find them with [`GET /advertiser-dashboard/geo-targets`](/api-reference/advertiser/targeting#get-/advertiser-dashboard/geo-targets). Gravity combines them with `allowed_countries`. An ID that isn't in the catalog returns `422`. **allowed\_devices** `array` The platforms to target: `web`, `ios_app`, `android_app`. Omit the field, or send all three, for no platform limit. Gravity also accepts the legacy values `iphone`, `android`, `desktop`, `tablet`, and `mobile`. **english\_only** `boolean` (default: false) Serve only in conversations that Gravity detects as English. Gravity still serves in conversations when the language is not clear. **network\_mode** `string` `dynamic`, `gravity`, or `openai`. OpenAI-only delivery needs account access. Omit the field to use the default of your account. **include\_audience\_ids** `array` Serve only to members of these [audiences](/api-reference/advertiser/targeting). Only a campaign that delivers fully on a partner delivery network accepts audiences, and only when your account has partner-network delivery. The campaign launches when the `delivery_status` of each list is ready. **exclude\_audience\_ids** `array` Never serve to members of these audiences. The same limits apply. One audience can't be both included and excluded. **ad\_groups** `array` The ad groups to create with the campaign. Each item has the same shape as the body of [`POST /advertiser-dashboard/ad-groups`](/api-reference/advertiser/ad-groups#post-/advertiser-dashboard/ad-groups), without `campaign_id`. Gravity sets `campaign_id` from the new campaign. Manual groups can include authored `ads`. Generative groups make their own ads and reject authored `ads`. **ad\_unit\_ids** `array` Legacy. The IDs of ad units to link. Gravity still supports them, but ad groups are the current model. > [!NOTE] > > Send at least one ad group in `ad_groups` or one ID in `ad_unit_ids`. If both are empty, the response is `422`. The body rejects fields that it does not know, for example `keywords`, `network_type`, and `network_config`, with `422`. Use `network_mode` to select the network. If you create the campaign with `"status": "active"` and your billing is not ready, the response is `402`. The ad group objects also have target and budget fields (`context`, `landing_page`, `daily_budget`, `max_cpc`, `allowed_countries`, `allowed_devices`, and more). See [Manage ad groups and ads](/api-reference/advertiser/ad-groups). ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/campaigns \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Summer Sale", "landing_page": "https://acme.com/summer", "daily_budget": 100, "status": "draft", "campaign_objective": "clicks", "billing_model": "cpm", "bid_strategy": "lowest_cost", "allowed_countries": ["US", "CA"], "allowed_devices": ["web", "ios_app", "android_app"], "ad_groups": [ { "name": "Summer Sale (Generative)", "group_type": "generative", "context": "Summer footwear sale, focus on running shoes" }, { "name": "Summer Sale (Manual)", "group_type": "manual", "ads": [ { "headline": "50% Off Running Shoes", "copy": "Summer sale on all running shoes.", "cta": "Shop Now", "landing_page": "https://acme.com/summer" } ] } ] }' ``` ```json Response 201 theme={null} { "campaign_id": "camp-456", "user_id": "adv-123", "name": "Summer Sale", "landing_page": "https://acme.com/summer", "daily_budget": 100, "budget_type": "daily", "status": "draft", "campaign_objective": "clicks", "billing_model": "cpm", "is_active": true, "created_at": "2025-05-01T00:00:00", "start_time": "2025-05-01T00:00:00", "end_time": null, "daily_budget_remaining": 100, "allowed_countries": ["US", "CA"], "allowed_devices": ["web", "ios_app", "android_app"], "utm_params": null, "custom_favicon_url": null, "deployment_status": "pending", "review_status": "not_submitted", "submitted_for_review_at": null, "reviewed_at": null, "review_notes": null, "delivery_status": "draft", "delivery_issues": [], "ad_groups": [ { "id": "adg-789", "campaign_id": "camp-456", "name": "Summer Sale (Generative)", "group_type": "generative", "status": "active", "ads": [], "ads_total": 0 }, { "id": "adg-790", "campaign_id": "camp-456", "name": "Summer Sale (Manual)", "group_type": "manual", "status": "active", "ads": [ { "id": "ad-901", "ad_group_id": "adg-790", "campaign_id": "camp-456", "headline": "50% Off Running Shoes", "copy": "Summer sale on all running shoes.", "cta": "Shop Now", "landing_page": "https://acme.com/summer", "source": "manual", "status": "active", "review_status": "pending" } ], "ads_total": 1 } ] } ``` ## PATCH `/advertiser-dashboard/campaigns/{campaign_id}` Change a campaign, or pause and resume it. Send only the fields that you want to change. **campaign\_id** `string` *required* The campaign ID. **name** `string` The campaign name. **landing\_page** `string` The landing page URL. **daily\_budget** `number` The average daily budget. You can send `null` to remove it only on a `target_cpa` campaign. **budget\_type** `string` `daily` or `total`. You can change it only before the campaign launches on OpenAI. **total\_budget** `number` The maximum spend for a total budget campaign. **monthly\_budget** `number` The spend limit for each calendar month (`target_cpa` only). Send `null` to remove it. **campaign\_objective** `string` The campaign objective: `clicks`, `reach`, or `conversions`. **billing\_model** `string` `cpm`. (`cpc` is still accepted for older campaigns; Gravity does not offer click billing.) You can change it only before the campaign launches on OpenAI. After that, a different value returns `422`. **conversion\_type** `string` The conversion event that the campaign optimizes and reports on. Send `null` or an empty string to remove it. **max\_cpc** `number` Legacy. The fallback bid for click-billed ad groups that have no `max_cpc`. Send `null` or `0` to remove it. This field does not change the billing model. **status** `string` `active`, `paused`, or `draft`. **start\_time** `string` The start time (ISO 8601). **end\_time** `string` The end time (ISO 8601). **allowed\_countries** `array` The countries to target. **geo\_targets** `array` Location IDs of countries, regions, and DMAs. Send an empty list or `null` to remove the limit. **allowed\_devices** `array` The platforms to target: `web`, `ios_app`, `android_app`. Send an empty list to remove the limit. **english\_only** `boolean` Serve only in conversations that Gravity detects as English. **network\_mode** `string` `dynamic`, `gravity`, or `openai`. **utm\_params** `array` UTM parameters as `{ "key": "...", "value": "..." }` pairs. **include\_audience\_ids** `array` Serve only to members of these audiences. Send an empty list to remove them. **exclude\_audience\_ids** `array` Never serve to members of these audiences. Send an empty list to remove them. **custom\_favicon\_url** `string` The favicon URL. It must start with `http://` or `https://` and have at most 1,000 characters. When you activate a campaign, Gravity does these checks: * If your billing is not ready, the response is `402`. * If all ad groups of the campaign are paused, the response is `409`. Resume one ad group first. * If a conversion campaign has no CAPI event for its result event, the response is `409`. See [Server-side conversions](/advertisers/server-conversions). Activation can send the campaign to review again and start a sync in the background. A change to the budget or the network can move the campaign to Gravity-only delivery. If the body has no fields to change, the response is `422`. ```bash Request theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456 \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "status": "paused" }' ``` ```json Response theme={null} { "campaign_id": "camp-456", "user_id": "adv-123", "name": "Summer Sale", "landing_page": "https://acme.com/summer", "daily_budget": 100, "status": "paused", "campaign_objective": "conversions", "is_active": false, "created_at": "2025-05-01T00:00:00", "start_time": "2025-05-01T00:00:00", "end_time": null, "daily_budget_remaining": 87.5, "allowed_countries": ["US", "CA"], "allowed_devices": ["web", "ios_app"], "utm_params": null, "custom_favicon_url": null, "deployment_status": "deployed", "review_status": "approved", "submitted_for_review_at": "2025-05-01T00:00:00", "reviewed_at": "2025-05-01T01:00:00", "review_notes": null } ``` ## PATCH `/advertiser-dashboard/campaigns/{campaign_id}/archive` Archive a campaign, or restore an archived campaign. Each call changes the archived state. **campaign\_id** `string` *required* The campaign ID. When you archive a campaign, Gravity also sets `is_active = false` and `status = "paused"`. When you restore it, the campaign stays paused. To start it again, send `PATCH /advertiser-dashboard/campaigns/{campaign_id}` with `"status": "active"`. ```bash Request theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456/archive \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "campaign_id": "camp-456", "is_archived": true } ``` # Full dashboard access Source: https://docs.trygravity.com/api-reference/advertiser/dashboard-access Use the /dashboard routes to reach every dashboard feature with your API key. The Platform API also serves the routes of the advertiser dashboard under `https://platform.trygravity.com/dashboard`. Each request runs the same code as the dashboard, as the account owner with the admin role. Send your API key in `X-API-Key` or as `Authorization: Bearer YOUR_API_KEY`. * Requests stay in the account of the key. A path parameter that names a different account, organization, or publisher returns `404`. * Some actions aren't available with a key: creating, switching, or closing an organization, the CLI login, and routes with no screen in the dashboard. * A key that isn't valid returns `401`. * The bridge has no published schema, so these docs don't list its routes. Use the other reference pages where they cover your task. An agent can find the bridged routes as `dashboard_*` tools on the [dashboard MCP server](/advertisers/dashboard-mcp). > [!WARNING] > > These routes can do anything an account admin can do in the dashboard, with no confirmation step. Keep the key on your server. # Deprecated endpoints and fields Source: https://docs.trygravity.com/api-reference/advertiser/deprecated Legacy ad unit endpoints and legacy fields that still work but have newer replacements. These endpoints and fields still work for current integrations, but each has a newer replacement. Use the replacement in new integrations. > [!WARNING] > > Ad units are the legacy creative model. They continue to work for current integrations. For new integrations, use [ad groups and ads](/api-reference/advertiser/ad-groups). A campaign contains ad groups, and an ad group contains ads. ## Legacy fields | Field | Where | Use instead | | - | - | - | | `max_cpc` | [Campaign create and update](/api-reference/advertiser/campaigns) | Set `max_cpc` on each ad group. The campaign value is only a fallback bid for click-billed ad groups that have none. | | `ad_unit_ids` | [Campaign create](/api-reference/advertiser/campaigns#post-/advertiser-dashboard/campaigns) | Send `ad_groups`. | | `daily_budget` | [Ad group create](/api-reference/advertiser/ad-groups#post-/advertiser-dashboard/ad-groups) | A legacy stored field. Set the delivery budget on the campaign. | | `iphone`, `android`, `desktop`, `tablet`, `mobile` | Campaign `allowed_devices` | `web`, `ios_app`, `android_app`. | ## Legacy ad unit endpoints The endpoints below create and link ad units. ### POST `/advertiser-dashboard/ad-units` Create an ad unit in your advertiser account. The response is `201`. **name** `string` *required* The ad unit name, at most 500 characters. **headline** `string` The headline, at most 80 characters. **description** `string` *required* The description. **landing\_page** `string` *required* The landing page URL. **static\_ad\_text** `string` *required* The ad text, at most 500 characters. **static\_cta** `string` *required* The call to action, at most 80 characters. **favicon\_url** `string` The favicon URL. **image\_url** `string` The image URL. **brand\_name** `string` The brand that you advertise. Users see it. Send the product or company that you advertise. Do not send an internal account, workspace, or person label. Gravity uses it as the title of the ad when there is no `headline`. **utm\_params** `array` UTM parameters as `{ "key": "...", "value": "..." }` pairs. > [!NOTE] > > If `description`, `landing_page`, `static_ad_text`, or `static_cta` is empty, the response is `422` with the names of the empty fields. The body rejects fields that it does not know with `422`. This includes `ad_text_mode` and the fields that the server manages: `source`, `status`, `review_status`, `review_reason`, `metadata`, and `generate_static_text`. ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/ad-units \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Summer headline variant", "headline": "Postgres for teams", "description": "Launch faster with managed Postgres.", "landing_page": "https://acme.com/summer", "static_ad_text": "Managed Postgres with built-in scaling.", "static_cta": "Start free", "brand_name": "Acme" }' ``` ```json Response 201 theme={null} { "id": "ad-123", "name": "Summer headline variant", "headline": "Postgres for teams", "description": "Launch faster with managed Postgres.", "landing_page": "https://acme.com/summer", "static_ad_text": "Managed Postgres with built-in scaling.", "static_cta": "Start free", "favicon_url": "https://acme.com/favicon.ico", "image_url": null, "brand_name": "Acme", "source": "manual", "status": "draft", "review_status": "not_submitted", "review_reason": null, "utm_params": [], "created_at": "2025-05-01T00:00:00", "updated_at": "2025-05-01T00:00:00", "campaign_id": null } ``` ### POST `/advertiser-dashboard/campaigns/{campaign_id}/ad-units/generate` Make draft ad units from a landing page URL. **campaign\_id** `string` *required* The campaign ID. **site\_url** `string` *required* The landing page URL that Gravity reads. It must be an `http` or `https` URL. If it is not valid, the response is `400`. **count** `integer` (default: 3) The number of draft ad units to make, from 1 to 5. If Gravity cannot make ad units from the page (for example, the page blocks automated access), the response is `422`. ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456/ad-units/generate \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "site_url": "https://acme.com/summer", "count": 3 }' ``` ```json Response theme={null} { "campaign_id": "camp-456", "ad_units": [ { "id": "ad-123", "name": "Managed Postgres for teams", "headline": "Postgres for teams", "description": "Launch faster with managed Postgres.", "landing_page": "https://acme.com/summer", "static_ad_text": "Managed Postgres with built-in scaling.", "static_cta": "Start free", "favicon_url": "https://acme.com/favicon.ico", "image_url": null, "brand_name": "Acme", "source": "scraped", "status": "draft", "review_status": "not_submitted", "review_reason": null, "utm_params": [], "created_at": "2025-05-01T00:00:00", "updated_at": "2025-05-01T00:00:00", "campaign_id": null } ] } ``` ### POST `/advertiser-dashboard/campaigns/{campaign_id}/ad-units` Add ad units to a campaign. The ad units that are already linked stay linked. **campaign\_id** `string` *required* The campaign ID. **ad\_unit\_ids** `array` *required* One or more ad unit IDs. Gravity removes duplicate IDs before it writes. The response lists all the ad units that are now linked to the campaign. Gravity marks the linked units for review when necessary. If an approved campaign now has ad units with no review, Gravity sends the campaign to review again. Then Gravity starts the ad unit sync for the campaign in the background. If an ad unit is not in your account, the response is `404`. ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456/ad-units \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "ad_unit_ids": ["ad-123", "ad-456"] }' ``` ```json Response theme={null} { "campaign_id": "camp-456", "ad_unit_ids": ["ad-123", "ad-456"] } ``` ### PUT `/advertiser-dashboard/campaigns/{campaign_id}/ad-units` Replace the set of ad units on a campaign. > [!WARNING] > > This call removes each linked ad unit that is not in `ad_unit_ids`. An empty list removes all the ad units from the campaign. To add ad units and keep the current ones, use the `POST` endpoint above. **campaign\_id** `string` *required* The campaign ID. **ad\_unit\_ids** `array` *required* The new ad unit IDs. Gravity removes duplicate IDs before it writes. This endpoint has the same ownership checks and the same review and sync behavior as the `POST` endpoint. ```bash Request theme={null} curl -X PUT https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456/ad-units \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "ad_unit_ids": ["ad-456", "ad-789"] }' ``` ```json Response theme={null} { "campaign_id": "camp-456", "ad_unit_ids": ["ad-456", "ad-789"] } ``` ### PATCH `/advertiser-dashboard/ad-units/{ad_unit_id}/archive` Archive an ad unit, or restore an archived ad unit. Each call changes the archived state. **ad\_unit\_id** `string` *required* The ad unit ID. When you restore an ad unit, its status changes to `"draft"`. ```bash Request theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/ad-units/ad-123/archive \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "ad_unit_id": "ad-123", "status": "archived" } ``` # Errors Source: https://docs.trygravity.com/api-reference/advertiser/errors The error statuses of the advertiser Dashboard API and what causes them. Every error response is JSON with a `detail` field that says what went wrong. The table lists each status and its common causes. | Status | Cause | | - | - | | `400` | A parameter is not valid. For example, a metrics date is not `YYYY-MM-DD`, `tz` is not a valid time zone, or `site_url` is not an `http(s)` URL. | | `401` | The API key is not valid. | | `402` | Your billing blocks the activation of the campaign. | | `403` | The advertiser account is archived and is no longer active. | | `404` | The campaign, ad group, ad, ad unit, audience, or geo target does not exist, or it is not in your account. | | `409` | A conflict. For example, a pixel for the URL already exists, all ad groups are paused, a conversion campaign has no CAPI event, or the campaign changed after you read it. | | `429` | Too many requests. Wait for the number of seconds in the `Retry-After` header, then try again. See [Rate limits](/api-reference/advertiser/overview#rate-limits). | | `411` / `413` / `429` | An audience upload has no `Content-Length`, is too large, or goes over the upload limit. See [`POST /advertiser-dashboard/audiences/custom-list`](/api-reference/advertiser/targeting#post-/advertiser-dashboard/audiences/custom-list). | | `422` | The request is not valid. For example, the `X-API-Key` header is not there, `days` is out of range, a campaign has no ad groups and no `ad_unit_ids`, the body has a field that the API does not accept (such as `keywords` or `ad_text_mode`), a required ad unit field is empty, the favicon URL is not valid, or a `PATCH /campaigns/{campaign_id}` body has no fields. | # Advertiser APIs Source: https://docs.trygravity.com/api-reference/advertiser/overview Base URL, authentication, limits, and conventions for the advertiser Dashboard API. The advertiser Dashboard API lets you read reports and manage campaigns, ad groups, targeting, and pixels with your API key. For short recipes, see the [Dashboard API guide](/advertisers/dashboard-api). To send conversions from your server, use the [Conversions API](/advertisers/server-conversions). ## Base URL ``` https://platform.trygravity.com ``` ## Authentication Send your advertiser API key in the `X-API-Key` header on every request. You'll find the key in the [Gravity dashboard](https://app.trygravity.com) under **Settings → Organization**. ```bash theme={null} curl https://platform.trygravity.com/advertiser-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` > [!WARNING] > > The API key has full access to your advertiser account. Keep it on your server. The key has no scopes. Anyone with it can read your data, create and change campaigns, and activate spend. Don't put it in browser code, a mobile app, or a public repository. Your account has one API key. The Dashboard API, the Conversions API, MMP postbacks, and the [dashboard MCP server](/advertisers/dashboard-mcp) all use it. A user with permission to edit the organization settings can regenerate it in **Settings → Organization**. When the key is regenerated, the old key stops working at once, so update every integration and agent that uses it. If the `X-API-Key` header is missing, the response is `422`. If the key isn't valid, the response is `401` with `Invalid API key`. See [Errors](/api-reference/advertiser/errors). ## Rate limits The Platform API allows about 100 requests per second from one client IP address. Over the limit, the response is `429` with a `Retry-After` header. Wait that many seconds, then try again. ## Conventions * Request and response bodies are JSON, except the audience upload, which is `multipart/form-data`. * Dates in query parameters use `YYYY-MM-DD`. Report endpoints take `tz`, an IANA time zone that sets where each day starts and ends. * Spend and budgets are in dollars. Conversion values in `/conversions` use `value_cents`. * `PATCH` endpoints change only the fields that you send. ## Pagination List endpoints that can return many rows page in one of two ways: * **Cursor:** [`/conversions`](/api-reference/advertiser/reports#get-/advertiser-dashboard/conversions) and [`/geo-targets`](/api-reference/advertiser/targeting#get-/advertiser-dashboard/geo-targets) return `has_more` and `next_cursor`. Send `next_cursor` as `cursor` to get the next page. * **Offset:** [`/ad-group-metrics`](/api-reference/advertiser/reports#get-/advertiser-dashboard/ad-group-metrics) returns at most 2,000 ad groups per call, with `total_ad_groups` and `truncated`. While `truncated` is `true`, send the request again with `offset` increased by 2000. ## Questions Send an email to [support@trygravity.com](mailto:support@trygravity.com) for all API questions. # Pixels Source: https://docs.trygravity.com/api-reference/advertiser/pixels Create and list the conversion pixels of your account. Use these endpoints to create a conversion pixel for a site and to list your pixels. To send conversions from your backend, use the [Conversions API](/advertisers/server-conversions). ## POST `/advertiser-dashboard/pixels` Create a conversion pixel for a site. After Gravity creates the pixel, it checks the pixel installation in the background. The response is `201`. **url** `string` *required* The URL of the site for the pixel. If the URL has no scheme, Gravity adds `https://`. **name** `string` The display name of the pixel. The default is `Untitled Pixel`. `is_functional` starts as `null`. After the background check, it changes to `true` or `false`. The value stays `null` if Gravity cannot do the check, for example on a private URL. If a pixel for the URL already exists, the response is `409`. If the URL is not valid, the response is `422`. ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/pixels \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://acme.com", "name": "Acme main site" }' ``` ```json Response 201 theme={null} { "pixel_id": "pix-123", "url": "https://acme.com", "name": "Acme main site", "is_active": true, "is_functional": null, "account_id": "acct-789", "created_at": "2026-07-01T12:00:00" } ``` ## GET `/advertiser-dashboard/pixels` List all the pixels of your account, newest first. ```bash Request theme={null} curl https://platform.trygravity.com/advertiser-dashboard/pixels \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} [ { "pixel_id": "pix-123", "url": "https://acme.com", "name": "Acme main site", "is_active": true, "is_functional": true, "account_id": "acct-789", "created_at": "2026-07-01T12:00:00" } ] ``` # Reports and account data Source: https://docs.trygravity.com/api-reference/advertiser/reports Read your profile, campaigns, metrics, conversions, billing, pixel events, and integrations. Use these read-only endpoints to pull your advertiser data: your profile, campaigns with their statistics, daily metrics at the campaign, ad group, and ad unit level, conversions, billing, pixel events, and connected stores. ## GET `/advertiser-dashboard/info` Get your advertiser profile. **X-API-Key** `string` *required* Your advertiser API key. ```bash Request theme={null} curl https://platform.trygravity.com/advertiser-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "advertiser_id": "abc-123", "name": "Acme Corp", "email": "ads@acme.com" } ``` ## GET `/advertiser-dashboard/campaigns` List all your campaigns with their lifetime statistics (impressions, clicks, spend, and conversions). **status** `string` Show only the campaigns with this status: `active`, `paused`, or `draft`. | Field | Description | | - | - | | `status` | The status that you set: `active`, `paused`, or `draft`. | | `delivery_status` | Tells if the campaign can serve now: `draft`, `scheduled`, `pending_review`, `eligible`, `limited`, `not_eligible`, `paused`, or `ended`. | | `delivery_issues` | The reasons when delivery is limited or blocked, for example `all_ads_rejected` or `ad_image_required`. | ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/campaigns" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "campaigns": [ { "campaign_id": "camp-456", "name": "Summer Sale", "status": "active", "is_active": true, "daily_budget": 100.0, "budget_type": "daily", "landing_page": "https://acme.com/summer", "campaign_objective": "conversions", "billing_model": "cpm", "created_at": "2025-05-01T00:00:00", "start_time": "2025-05-01T00:00:00", "end_time": null, "delivery_status": "eligible", "delivery_issues": [], "impressions": 350000, "clicks": 1800, "spend": 875.50, "conversions": 42, "click_through_conversions": 40, "view_through_conversions": 2, "conversion_value": 4200.00 } ] } ``` ## GET `/advertiser-dashboard/campaigns/{campaign_id}` Get one campaign with its full statistics and its target settings. **campaign\_id** `string` *required* The campaign ID. The response also has all the fields of the list response. If the campaign is not in your account, the response is `404`. ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "campaign_id": "camp-456", "name": "Summer Sale", "status": "active", "is_active": true, "daily_budget": 100.0, "landing_page": "https://acme.com/summer", "campaign_objective": "conversions", "created_at": "2025-05-01T00:00:00", "start_time": "2025-05-01T00:00:00", "end_time": null, "allowed_countries": ["US", "CA"], "allowed_devices": ["web", "ios_app"], "utm_params": [{ "key": "utm_source", "value": "gravity" }], "deployment_status": "deployed", "impressions": 350000, "clicks": 1800, "spend": 875.50, "conversions": 42, "conversion_value": 4200.00 } ``` ## GET `/advertiser-dashboard/metrics` Get daily performance metrics. **days** `integer` (default: 30) The number of days to look back, from 1 to 365. **start\_date** `string` The first day (`YYYY-MM-DD`). If you send only `start_date`, the range ends today. **end\_date** `string` The last day (`YYYY-MM-DD`). If you send only `end_date`, the range starts `days` before it. **campaign\_id** `string` Show only one campaign. Omit it to get the totals for all campaigns. **tz** `string` (default: UTC) The IANA time zone (for example `America/New_York`) that sets the start and end of each day. Set it to the time zone that your ad account uses for reports. Then the daily rows agree with the Gravity dashboard. For ChatGPT and OpenAI campaigns, they also agree with the OpenAI reports. OpenAI ad accounts currently report in `America/New_York`. > [!NOTE] > > The `tz` parameter sets the days, not the time zone of the machine that calls the API. A day in UTC and the same day in `America/New_York` cover different hours, so the totals are different. Choose one time zone and always use it. If `start_date` or `end_date` is not a valid date, or `tz` is not a valid time zone, the response is `400`. | Metric | Description | | - | - | | `ctr` | The click-through rate (clicks / impressions × 100). | | `cpc` | The cost per click (spend / clicks). | | `cpm` | The cost per thousand impressions (spend / impressions × 1000). | | `conversion_rate` | Conversions / clicks × 100. | | `cpa` | The cost per acquisition (spend / conversions). | | `roas` | The return on ad spend (revenue / spend). | | `click_through_conversions` / `view_through_conversions` | The conversions after a click, and the conversions after a view with no click. | ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/metrics?days=7&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "data": [ { "date": "2025-05-20", "impressions": 50000, "clicks": 260, "spend": 125.00, "conversions": 6, "click_through_conversions": 6, "view_through_conversions": 0, "revenue": 600.00, "ctr": 0.52, "cpc": 0.48, "cpm": 2.50, "conversion_rate": 2.31, "cpa": 20.83, "roas": 4.80 } ], "total_impressions": 350000, "total_clicks": 1800, "total_spend": 875.50, "total_conversions": 42, "total_click_through_conversions": 40, "total_view_through_conversions": 2, "total_revenue": 4200.00 } ``` ## GET `/advertiser-dashboard/ad-unit-metrics` Get daily metrics for each ad unit. These are the numbers in **Ad units** on the detail page of a campaign. Use this endpoint for reports at the creative level. The response has one series for each link between a campaign and an ad unit. Each series has a `data_source`: * `ad_unit_stats`: the series comes from real statistics for the ad unit. * `campaign_fallback`: the campaign has one linked ad unit and no ad unit rows yet. The series uses the daily totals of the campaign, so the numbers agree with the dashboard. > [!NOTE] > > This endpoint gives impressions, clicks, spend, CTR, CPC, and CPM for each ad unit. Gravity attributes conversions, CPA, and ROAS at the campaign level. For those metrics, use `/metrics` or `/conversions`. **days** `integer` (default: 30) The number of days to look back, from 1 to 365. **start\_date** `string` The first day (`YYYY-MM-DD`). The rules are the same as for `/metrics`. **end\_date** `string` The last day (`YYYY-MM-DD`). **campaign\_id** `string` Show only one campaign. **ad\_unit\_id** `string` Show only one ad unit. If the ad unit is not in your account, the response is `404`. **tz** `string` (default: UTC) The IANA time zone (for example `America/New_York`) that sets the days. It works the same as on `/metrics`. | Field | Description | | - | - | | `data_source` | `ad_unit_stats` for real ad unit rows. `campaign_fallback` when a campaign with one ad unit uses the daily totals of the campaign. | | `total_impressions` / `total_clicks` / `total_spend` | The totals of the series for the range. | | `data[].ctr` | The click-through rate (clicks / impressions × 100). | | `data[].cpc` | The cost per click (spend / clicks). | | `data[].cpm` | The cost per thousand impressions (spend / impressions × 1000). | ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/ad-unit-metrics?days=7&campaign_id=camp-456&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "ad_units": [ { "ad_unit_id": "ad-123", "campaign_id": "camp-456", "name": "Character & Brand Voice", "data_source": "ad_unit_stats", "total_impressions": 19784, "total_clicks": 131, "total_spend": 88.20, "data": [ { "date": "2025-05-20", "impressions": 3200, "clicks": 22, "spend": 14.60, "ctr": 0.69, "cpc": 0.66, "cpm": 4.56 } ] } ] } ``` ## GET `/advertiser-dashboard/ad-group-metrics` Get daily metrics for each ad group. A campaign contains ad groups, and an ad group contains ads. Use this endpoint for ad group reports, for example a weekly CSV. The response has one series for each ad group, with the sum for all publishers. * An ad group with no delivery in the range is in the list. It has zero totals and an empty `data` array. * A day with conversions but no delivery is in the list, with zero impressions. * One call returns at most 2,000 ad groups. Use `total_ad_groups`, `truncated`, and `offset` to get all of them. > [!NOTE] > > Gravity counts conversions here the same way as campaign `/metrics`. It removes duplicates and applies the same attribution window. Currently, a conversion counts when it occurs within 7 days of a Gravity click or a Gravity view. This window can change. Gravity gives each conversion to the ad group that served the click. So the ad groups of a campaign never have more conversions than the campaign. > > Some conversions have no ad group: conversions from clicks before Gravity added ad group attribution, and view-through conversions. These show only in campaign `/metrics`. So the ad group conversions and Results of a campaign can be less than the campaign total. Use `/metrics` for the full count. Results count the conversion event that the campaign selected, the same way as Ads Manager. **days** `integer` (default: 30) The number of days to look back, from 1 to 365. **start\_date** `string` The first day (`YYYY-MM-DD`). The rules are the same as for `/metrics`. **end\_date** `string` The last day (`YYYY-MM-DD`). **campaign\_id** `string` Show only one campaign. If the campaign is not in your account, the response is `404`. **ad\_group\_id** `string` Show only one ad group. If the ad group is not in your account, the response is `404`. **include\_archived** `boolean` (default: false) Include archived ad groups. **offset** `integer` (default: 0) The number of ad groups to skip. While `truncated` is `true`, send the request again with `offset` increased by 2000. **tz** `string` (default: UTC) The IANA time zone (for example `America/New_York`) that sets the days. It works the same as on `/metrics`. | Field | Description | | - | - | | `total_ad_groups` | The number of ad groups that match your filters. This number ignores the limit of 2,000 for each call. | | `truncated` | `true` when more ad groups match than the response contains. Send the request again with a higher `offset`. | | `group_type` | `generative` or `manual`. | | `total_impressions` / `total_clicks` / `total_spend` / `total_conversions` / `total_results` / `total_revenue` / `total_result_revenue` | The totals of the series for the range. | | `result_type` / `result_label` | The result event that the campaign selected, for example `purchase` / `Purchase`. The value is `null` when the campaign objective has no Results. | | `cpa` | The cost per acquisition (spend / conversions). The value is `null` when the group has no conversions. | | `cost_per_result` | Spend / Results. The value is `null` when there are no Results. | | `conversion_rate` | Results / clicks × 100. The value is `null` when there are no clicks. | | `data[].conversions` | The conversions that Gravity attributed to this ad group on that day, in your `tz`. | | `data[].click_through_conversions` / `data[].view_through_conversions` | The conversions of that day, divided into click-through and view-through. | | `data[].results` | The conversions that match the result event of the campaign. | | `data[].revenue` / `data[].result_revenue` | The conversion value in USD for all conversions, and for Results only. The value is `null` when no conversion has a USD value. | | `data[].ctr` | The click-through rate (clicks / impressions × 100). | | `data[].cpc` | The cost per click (spend / clicks). | | `data[].cpm` | The cost per thousand impressions (spend / impressions × 1000). | | `data[].cpa` / `data[].cost_per_result` / `data[].conversion_rate` | The same as the totals above, for that day. | ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/ad-group-metrics?days=7&campaign_id=camp-456&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "ad_groups": [ { "ad_group_id": "adg-789", "campaign_id": "camp-456", "name": "Summer Sale (Generative)", "group_type": "generative", "status": "active", "total_impressions": 1500, "total_clicks": 25, "total_spend": 7.50, "total_conversions": 4, "total_click_through_conversions": 4, "total_view_through_conversions": 0, "result_type": "purchase", "result_label": "Purchase", "total_results": 2, "total_revenue": 120.00, "total_result_revenue": 120.00, "cpa": 1.88, "cost_per_result": 3.75, "conversion_rate": 8.00, "data": [ { "date": "2025-05-20", "impressions": 1000, "clicks": 20, "spend": 5.00, "conversions": 3, "click_through_conversions": 3, "view_through_conversions": 0, "results": 2, "revenue": 120.00, "result_revenue": 120.00, "ctr": 2.00, "cpc": 0.25, "cpm": 5.00, "cpa": 1.67, "cost_per_result": 2.50, "conversion_rate": 10.00 } ] } ], "total_ad_groups": 1, "offset": 0, "truncated": false } ``` ## GET `/advertiser-dashboard/conversions` List your recent conversions, one page at a time. The list includes conversions with and without attribution. **limit** `integer` (default: 50) The page size, from 1 to 100. **cursor** `string` The pagination cursor. Send the `next_cursor` value from the previous response. **campaign\_id** `string` Show only one campaign. If the campaign is not in your account, the response is `404`. **event\_type** `string` Show only one conversion event type, for example `purchase` or `lead`. **attribution\_type** `string` Show only `click_through` or `view_through` conversions. When you set this filter, the response does not include conversions without attribution. ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/conversions?limit=10" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "conversions": [ { "conversion_id": "conv-789", "event_type": "purchase", "value_cents": 9999, "currency": "USD", "conversion_timestamp": "2025-05-20T14:30:00", "campaign_id": "camp-456", "order_id": "order-abc", "page_url": "https://acme.com/checkout/success", "source": "capi", "is_attributed": true, "attribution_type": "click_through" } ], "total_conversions": 42, "total_click_through_conversions": 40, "total_view_through_conversions": 2, "has_more": true, "next_cursor": "conv-788" } ``` ## GET `/advertiser-dashboard/billing` Get a billing summary: total spend, billed amount, unbilled amount, and credits. ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/billing" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "total_spend_dollars": 875.50, "total_billed_dollars": 800.00, "unbilled_dollars": 75.50, "credits_dollars": 0.00, "billing_enabled": true, "last_spend_sync_at": "2025-05-20T15:00:00", "last_billed_at": "2025-05-15T00:00:00" } ``` ## GET `/advertiser-dashboard/events/analytics` Get the event analytics of your pixel: event counts for each domain, a time series, and counts for each event type. **days** `integer` (default: 30) The number of days to look back, from 1 to 365. **start\_date** `string` The first day (`YYYY-MM-DD`). The rules are the same as for `/metrics`. **end\_date** `string` The last day (`YYYY-MM-DD`). **domain** `string` Show only the domains that contain this text. **event\_type** `string` Show only one event type, for example `page_view`, `form_submit`, or `click`. ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/events/analytics?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "domains": [ { "domain": "acme.com", "events": 12500 }, { "domain": "blog.acme.com", "events": 3200 } ], "time_series": [ { "date": "2025-05-20T00:00:00", "events": 850 }, { "date": "2025-05-20T01:00:00", "events": 720 } ], "event_types": [ { "event_type": "page_view", "total": 10000, "last_received": "2025-05-20T15:00:00" }, { "event_type": "form_submit", "total": 500, "last_received": "2025-05-20T14:30:00" } ], "has_historical_data": true } ``` ## GET `/advertiser-dashboard/events/conversion-stats` Get a conversion time series: the daily totals and the totals for each event type. **days** `integer` (default: 30) The number of days to look back, from 1 to 365. **start\_date** `string` The first day (`YYYY-MM-DD`). The rules are the same as for `/metrics`. **end\_date** `string` The last day (`YYYY-MM-DD`). **event\_type** `string` Show only one conversion event type, for example `purchase` or `lead`. ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/events/conversion-stats?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "time_series": [ { "date": "2025-05-20T00:00:00", "conversions": 8 }, { "date": "2025-05-21T00:00:00", "conversions": 12 } ], "total": 42, "by_event_type": [ { "event_type": "purchase", "total": 30 }, { "event_type": "lead", "total": 12 } ] } ``` ## GET `/advertiser-dashboard/integrations` List your active Shopify store connections. To connect a store, see [Integrations](/advertisers/integrations). ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/integrations" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "shopify": [ { "id": "conn-123", "shop_domain": "acme-store.myshopify.com", "is_active": true, "connected_at": "2025-04-01T12:00:00", "verification_status": "verified" } ] } ``` # Targeting Source: https://docs.trygravity.com/api-reference/advertiser/targeting Look up geo targets and manage the customer-list audiences that campaigns target. Use these routes to find the values that a campaign's targeting fields accept. `geo_targets` takes location IDs for countries, regions, and DMAs from the geo-target catalog. `include_audience_ids` and `exclude_audience_ids` take the IDs of customer lists that you upload. Set these fields with [`POST /advertiser-dashboard/campaigns`](/api-reference/advertiser/campaigns#post-/advertiser-dashboard/campaigns) or [`PATCH /advertiser-dashboard/campaigns/{campaign_id}`](/api-reference/advertiser/campaigns#patch-/advertiser-dashboard/campaigns/campaign_id). ## GET `/advertiser-dashboard/geo-targets` Search the geo-target catalog, page through it, or resolve a batch of location IDs. The catalog has countries, regions, and DMAs. Results are ordered by target type, country code, name, and location ID. **q** `string` Matches a case-insensitive part of `name` or `canonical_name`, or an exact `location_id` or `country_code`. **country\_code** `string` Show only one country (ISO 3166-1 alpha-2). **target\_type** `string` Show only `country`, `region`, or `dma` entries. **location\_ids** `string` A comma-separated list of location IDs to resolve, at most 500. When you send it, Gravity ignores `q`, `country_code`, `target_type`, and `cursor`. The items keep the order that you asked for, and IDs that aren't in the catalog go to `not_found`. **limit** `integer` (default: 50) The page size. Gravity clamps it to the range 1 to 1000 and returns the value it used. **cursor** `string` The `next_cursor` value from the previous page. | Field | Description | | - | - | | `total` | The number of entries that match your filters, on all pages. | | `limit` | The page size after clamping. | | `offset` | The position of the first item of this page. | | `has_more` / `next_cursor` | Send `next_cursor` as `cursor` to get the next page. It's `null` on the last page. | | `not_found` | For a `location_ids` lookup only: the requested IDs that aren't in the catalog. | `AR`, `CL`, `CO`, `PE`, `UY`, `IL`, `SA`, and `TR` are Gravity country aliases, not catalog location IDs. A campaign accepts them in `geo_targets` and targets the whole country. A cursor that Gravity didn't return, or more than 500 `location_ids`, returns `422`. ```bash Request theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/geo-targets?q=california&target_type=region" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "items": [ { "location_id": "2000043", "name": "California", "canonical_name": "California, United States", "country_code": "US", "target_type": "region" } ], "total": 1, "limit": 50, "offset": 0, "has_more": false, "next_cursor": null, "not_found": [] } ``` ## GET `/advertiser-dashboard/geo-targets/{location_id}` Resolve one location ID. The response is one catalog entry, in the same shape as an item above. If the ID isn't a supported country, region, or DMA, the response is `404`. ## POST `/advertiser-dashboard/audiences/custom-list` Upload a customer list as a new audience. Send a `multipart/form-data` body. Gravity hashes emails and phone numbers when it reads the file and doesn't store the raw values. The response is `201` with the new audience. **file** `file` *required* A UTF-8 CSV file of emails, phone numbers, or SHA-256 hashes, one value in each cell. Separate values with commas or new lines. A first row of column headers (for example `email` or `phone`) is optional. When a header names a column as email or phone, Gravity reads the hashed values in that column as that type. At most 20 MB and 500,000 identifiers. **name** `string` The audience name, at most 255 characters. If you leave it out, Gravity makes a name from the file name. Gravity matches the list against its users in a background job that runs every hour. Until then, `size` and `last_materialized_at` are `null`. | Status | Cause | | - | - | | `400` | The file isn't UTF-8 text, the multipart body isn't valid, or the file has no valid identifiers. | | `409` | The account already has 100 uploaded lists. Delete one first. | | `411` | The request has no `Content-Length` header. | | `413` | The file is larger than 20 MB, or it has more than 500,000 identifiers. | | `422` | The `file` field is missing, or the name is longer than 255 characters. | | `429` | The account uploaded more than 20 lists in the last hour. | ```bash Request theme={null} curl -X POST https://platform.trygravity.com/advertiser-dashboard/audiences/custom-list \ -H "X-API-Key: YOUR_API_KEY" \ -F "name=Past customers" \ -F "file=@customers.csv" ``` ```json Response 201 theme={null} { "id": "6b1f0c9e-4d2a-4f53-9a77-1c2d3e4f5a6b", "account_id": "acct-789", "name": "Past customers", "source": "custom_list", "lookback_days": 180, "rule": { "identifier_count": 1200, "email_hashes": 1000, "phone_hashes": 200, "skipped_rows": 3, "filename": "customers.csv" }, "size": null, "last_materialized_at": null, "created_at": "2026-10-28T12:00:00Z", "updated_at": "2026-10-28T12:00:00Z", "delivery_status": { "status": "not_requested", "ready": false, "eligible_uses": [], "error": null } } ``` ## GET `/advertiser-dashboard/audiences` List the audiences of your account, newest first. Each item has the same shape as the upload response. Archived audiences aren't in the list. Besides uploaded lists (`custom_list`), `source` can be `ad_clicks`, `conversions`, `site_visits`, `ad_views`, `clicked_no_conversion`, `conversion_event`, or `composite` for audiences made in the dashboard. ## GET `/advertiser-dashboard/audiences/{audience_id}` Get one audience with its `delivery_status`. After you attach an uploaded list to a campaign, check this status until `ready` is `true`. | `delivery_status` field | Description | | - | - | | `status` | `not_requested`, `pending`, `processing`, `ready`, `error`, or `archived`. | | `ready` | `true` when the delivery network can use the list. | | `eligible_uses` | The uses that the network allows for the list after it's ready: `exclusion`, `inclusion`, or `bid_multiplier`. | | `error` | The reason when `status` is `error`. | If the audience isn't in your account, the response is `404`. ## DELETE `/advertiser-dashboard/audiences/{audience_id}` Archive an audience and remove it from the targeting of every campaign that uses it. The response is `204`. If the audience isn't in your account, the response is `404`. # Full dashboard access Source: https://docs.trygravity.com/api-reference/publisher/dashboard-access Use the /dashboard routes to reach every dashboard feature with your API key. The Platform API also serves the routes of the publisher dashboard under `https://platform.trygravity.com/dashboard`. Each request runs the same code as the dashboard, as the account owner with the admin role. Send your API key in `X-API-Key` or as `Authorization: Bearer YOUR_API_KEY`. * Requests stay in the account of the key. A path parameter that names a different account, organization, or publisher returns `404`. * Some actions aren't available with a key: creating, switching, or closing an organization, the CLI login, and routes with no screen in the dashboard. * A key that isn't valid returns `401`. * The bridge has no published schema, so these docs don't list its routes. Use the other reference pages where they cover your task. An agent can find the bridged routes as `dashboard_*` tools on the [dashboard MCP server](/ai-platforms/dashboard-mcp). > [!WARNING] > > These routes can do anything an account admin can do in the dashboard, with no confirmation step. Keep the key on your server. # Errors Source: https://docs.trygravity.com/api-reference/publisher/errors The error statuses of the publisher Dashboard API and what causes them. Every error response is JSON with a `detail` field that says what went wrong. The table lists each status and its common causes. | Status | Description | | - | - | | `400` | A parameter is not valid, for example a time zone that does not exist, a bad date, or a date range that is too long. | | `401` | The `X-API-Key` header is missing, or the key is not valid. | | `404` | The route isn't available for your account (the user value API is off, or you have no qualifying-request agreement), or the user isn't found. | | `409` | A placement with the same slug is already in your account. | | `422` | A required parameter is missing, or a body field or a parameter (for example `days`) is not valid. | | `429` | Too many requests. Wait for the number of seconds in the `Retry-After` header, then try again. See [Rate limits](/api-reference/publisher/overview#rate-limits). | # Publisher APIs Source: https://docs.trygravity.com/api-reference/publisher/overview Base URL, authentication, limits, and conventions for the publisher Dashboard API. The publisher Dashboard API gives you your dashboard numbers with code, creates placements, and reports on your users and agreements. For short recipes, see the [Dashboard API guide](/ai-platforms/dashboard-api). The reporting endpoints are read-only. `POST /publisher-dashboard/placements` creates a placement. ## Base URL ``` https://platform.trygravity.com ``` ## Authentication Send your Gravity API key in the `X-API-Key` header on every request. To find the key, open **Settings** in the [dashboard](https://app.trygravity.com) and copy it from the **API Key** card. It's the same key you use to request ads, so you don't need other credentials. ```bash theme={null} curl https://platform.trygravity.com/publisher-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` > [!WARNING] > > Keep the API key on your server. Anyone with the key can read your data and create placements. Your account has one API key, shared by ad requests, this API, and the [dashboard MCP server](/ai-platforms/dashboard-mcp). When anyone regenerates it, the old key stops working at once, and every integration and agent that uses it loses access. If the `X-API-Key` header is missing or the key isn't valid, the response is `401`. See [Errors](/api-reference/publisher/errors). ## Rate limits The Platform API allows about 100 requests per second from one client IP address. Over the limit, the response is `429` with a `Retry-After` header. Wait that many seconds, then try again. ## Conventions * Request and response bodies are JSON. * Most reports take `days`, or `start_date` and `end_date` (`YYYY-MM-DD`). A date range replaces `days`. Each endpoint has its own range limit. * `tz` is an IANA time zone for the daily buckets, for example `America/New_York`. The default is `UTC`. * Amounts that end in `_microdollars` are in millionths of a dollar. Other revenue fields are in dollars. ## Questions Send an email to [support@trygravity.com](mailto:support@trygravity.com) for questions about the API. # Placements Source: https://docs.trygravity.com/api-reference/publisher/placements Create placements and read the results of each placement. Use these endpoints to read the results of each placement (ad slot) and to create a placement without the dashboard. ## GET `/publisher-dashboard/placements` Returns the results for each placement, with a daily time series. **days** `integer` (default: 7) The number of days to look back, from 1 to 365. **start\_date** `string` The start date (`YYYY-MM-DD`). Use it with `end_date`. A date range replaces `days`. **end\_date** `string` The end date (`YYYY-MM-DD`). ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/placements?days=7" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "placements": [ { "placement": "below_response", "impressions": 120000, "clicks": 600, "ctr": 0.005, "revenue": 600.00, "cpm": 5.00, "cpc": 1.00, "daily": [ { "date": "2025-05-20", "impressions": 17000, "clicks": 85, "ctr": 0.005, "revenue": 85.00, "cpm": 5.00, "cpc": 1.00 } ] } ], "period_days": 7 } ``` ## POST `/publisher-dashboard/placements` Creates a placement (an ad slot). You do not need the dashboard. The `slug` in the response is the `placement_id` to send in ad requests. The new placement has the status `active`. Gravity refreshes the placement cache every 5 minutes. Until the refresh, ad requests get no ad for the new slot. **name** `string` *required* The display name, at most 100 characters. Gravity makes the slug from it (`"Sidebar Slot"` becomes `Sidebar-Slot`). The slug must be unique for your account. **placement\_type** `string` (default: below\_response) One of `above_response`, `below_response`, `inline_response`, `left_response`, `right_response`, `search_result`, `center_page`, `top_page`, `bottom_page`, `left_page`, `right_page`. **device** `string` (default: all) One of `all`, `desktop`, `mobile`, `tablet`. **framework** `string` (default: any) One of `any`, `react`, `nextjs`, `vite`, `react-native`, `swift`, `vanilla`, `other`. **page\_url** `string` The URL of the page or screen that has the slot. **description** `string` Your notes. **display\_config** `object` Optional `{ "max_ad_text_chars": integer }`. It sets the maximum length of the ad text for this slot. The minimum value is 120. `renderer_spec` is the ad design that the slot uses. `renderer_spec_source` tells you where the design comes from: `placement` (the design of this slot), `global` (the default design of the platform), or `null` (the built-in layout of the SDK). | Status | Reason | | - | - | | `409` | A placement with the same slug is already in your account. | | `422` | `name` is empty, has no letters or digits, or has more than 100 characters. Or an enum value is not valid, or `max_ad_text_chars` is less than 120. | ```bash Request theme={null} curl -X POST https://platform.trygravity.com/publisher-dashboard/placements \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Sidebar Slot", "placement_type": "right_response", "device": "desktop", "page_url": "https://myplatform.com/chat" }' ``` ```json Response 201 Created theme={null} { "id": "6f1c2d3e-...", "publisher_id": "abc-123", "name": "Sidebar Slot", "slug": "Sidebar-Slot", "placement_type": "right_response", "device": "desktop", "framework": "any", "page_url": "https://myplatform.com/chat", "description": null, "screenshot_url": null, "screenshot_region": null, "status": "active", "display_config": null, "renderer_spec": null, "renderer_spec_source": null, "created_at": "2026-09-10T20:45:00+00:00", "updated_at": "2026-09-10T20:45:00+00:00" } ``` # Reports and earnings Source: https://docs.trygravity.com/api-reference/publisher/reports Read your profile, earnings, request funnel, and results by device and country. Use these read-only endpoints to get the numbers from your publisher dashboard: your profile, daily earnings, the ad request funnel, and results by device and country. ## GET `/publisher-dashboard/info` Returns your publisher profile and lifetime totals. **X-API-Key** `string` *required* Your Gravity API key. ```bash Request theme={null} curl https://platform.trygravity.com/publisher-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "publisher_id": "abc-123", "name": "My Platform", "url": "https://myplatform.com", "payout_model": "CPM", "total_impressions": 7800000, "total_clicks": 42000 } ``` ## GET `/publisher-dashboard/stats` Returns a daily time series of impressions, clicks, revenue, CPM, CPC, and CTR. The values are the same as on the **Overview** and **Earnings** pages of the dashboard. **days** `integer` (default: 30) The number of days to look back, from 1 to 3650. **start\_date** `string` The start date (`YYYY-MM-DD`). Use it with `end_date`. A date range replaces `days`. **end\_date** `string` The end date (`YYYY-MM-DD`). **tz** `string` (default: UTC) The IANA time zone for the daily buckets, for example `America/New_York`. The response has more fields than the example. The example shows the main fields. ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/stats?days=7&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "totalImpressions": 250000, "totalClicks": 1200, "totalRevenue": 1250.50, "avgCpm": 5.00, "avgCpc": 1.04, "avgCtr": 0.48, "timeSeries": [ { "date": "2025-05-20", "impressions": 35000, "clicks": 170, "revenue": 178.50, "cpm": 5.10, "cpc": 1.05, "ctr": 0.49 } ] } ``` ## GET `/publisher-dashboard/activity` Returns the ad request funnel: requests, wins, impressions, fill rate, and show rate. **days** `integer` (default: 7) The number of days to look back, from 1 to 30. **start\_date** `string` The start date (`YYYY-MM-DD`). Use it with `end_date`. A date range replaces `days`. **end\_date** `string` The end date (`YYYY-MM-DD`). **tz** `string` (default: UTC) The IANA time zone for the daily buckets, for example `America/New_York`. | Field | Description | | - | - | | `fill_rate` | How often a campaign matched: `wins / requests`. | | `show_rate` | How often a matched ad became visible: `impressions / wins`. | | `bot_requests`, `total_bot_requests`, `bot_request_rate` | The requests that the engine marked as bot traffic. | ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/activity?days=7" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "data": [ { "date": "2025-05-20", "requests": 50000, "bot_requests": 500, "wins": 35000, "impressions": 30000 } ], "total_requests": 350000, "total_bot_requests": 3500, "bot_request_rate": 0.01, "total_wins": 245000, "total_impressions": 210000, "fill_rate": 0.70, "show_rate": 0.8571 } ``` ## GET `/publisher-dashboard/devices` Returns the results for each device type (desktop, mobile, and tablet). **days** `integer` (default: 30) The number of days to look back, from 1 to 365. **start\_date** `string` The start date (`YYYY-MM-DD`). Use it with `end_date`. A date range replaces `days`. **end\_date** `string` The end date (`YYYY-MM-DD`). **tz** `string` (default: UTC) The IANA time zone for the daily buckets, for example `America/New_York`. ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/devices?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "devices": [ { "device_type": "desktop", "impressions": 150000, "clicks": 750, "ctr": 0.005, "revenue": 750.00, "cpm": 5.00, "cpc": 1.00, "daily": [ { "date": "2025-05-20", "device_type": "desktop", "impressions": 5000, "clicks": 25, "ctr": 0.005, "revenue": 25.00, "cpm": 5.00, "cpc": 1.00 } ] } ], "period_days": 30 } ``` ## GET `/publisher-dashboard/geography` Returns the results for each country. **days** `integer` (default: 30) The number of days to look back, from 1 to 365. **start\_date** `string` The start date (`YYYY-MM-DD`). Use it with `end_date`. A date range replaces `days`. **end\_date** `string` The end date (`YYYY-MM-DD`). **tz** `string` (default: UTC) The IANA time zone for the daily buckets, for example `America/New_York`. ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/geography?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "countries": [ { "country_code": "US", "impressions": 500000, "clicks": 2500, "ctr": 0.005, "revenue": 2500.00, "cpm": 5.00, "cpc": 1.00 } ], "period_days": 30 } ``` ## GET `/publisher-dashboard/countries` Returns your top countries by number of requests, with the number of unique users in each country. **limit** `integer` (default: 20) The number of countries to return, from 1 to 100. ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/countries?limit=10" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "countries": [ { "country_code": "US", "requests": 200000, "ads_served": 140000, "unique_users": 8000, "pct_requests": 57.1 } ] } ``` # Revenue insights and agreement statements Source: https://docs.trygravity.com/api-reference/publisher/revenue See where your revenue per user comes from, and read the monthly statement of a qualifying-request agreement. Use these endpoints to see where your revenue per user comes from, and, if you have a qualifying-request agreement, to read its monthly statement. ## Revenue per user ### GET `/publisher-dashboard/user-analytics` See where your revenue per user comes from: by day, new and returning users, usage, ad load, country, signup week, and your own user tags. Every publisher can call this route. **days** `integer` (default: 30) The number of days to look back, from 1 to 365. Or send `start_date` and `end_date` (`YYYY-MM-DD`). **country** `string` Comma-separated ISO country codes, or `unknown`. At most 20 countries. **segment** `string` Comma-separated: `new`, `returning`. **usage** `string` Comma-separated usage groups: `under_10`, `10_24`, `25_49`, `50_99`, `100_plus` (requests per active day). Gravity ignores filter values that it doesn't know. | Field | Description | | - | - | | `start_date` / `end_date` / `period_days` | The range of the report. | | `data_since` | The first day that Gravity has user data for your account. | | `summary` | `active_users`, `new_users`, `returning_users`, `arpu`, `arpdau` (payout per active user day), `payout`, and `attributed_impression_share` (the share of impressions that Gravity could attribute to a user). | | `daily` | One row per day: `day`, `active_users`, `new_users`, `impressions`, `arpu`. | | `new_vs_returning` / `by_usage` / `by_ad_load` / `by_country` | Segment rows. Ad load groups are `none`, `under_5`, `5_9`, `10_19`, and `20_plus` impressions per active day. `by_country` has up to 25 countries. | | `concentration` | The revenue share of the top 1%, 5%, and 20% of users: `top_pct`, `users`, `revenue_share`. | | `signup_cohorts` | One row per signup week: `week`, `users`, `d7_retention`, `first_week_arpu`. The last two are `null` until every user in the week has had 7 full days. | | `tags` | ARPU for each value of your `user.tags` keys, up to 20 values per key: `key` and `values` (segment rows). | | `trends` | Daily ARPU for new and returning users and for the top countries: `section`, `segment`, and `points` (`day`, `users`, `arpu`). | Each segment row has `segment`, `users`, `user_days`, `impressions`, `clicks`, `ctr`, `arpu`, `arpdau`, and `revenue_share`. ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/user-analytics?days=30&segment=returning" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Agreement statements These routes return the monthly statement of a qualifying-request agreement. They exist only for publishers on that kind of agreement. For any other key, they return `404`. Both take `month` (any date in the month, `YYYY-MM-DD`; the default is the current month) and `tz` (an IANA time zone, default `UTC`). A bad `month` or `tz` returns `400`. A future month returns `404`. Amounts that end in `_microdollars` are in millionths of a dollar. ### GET `/publisher-dashboard/qualifying-requests` Get the qualifying requests, rate, tier, and earnings of one month. `accrued_minimum_microdollars` is the minimum for the days of the month that the agreement covers, not a full month. `earned_month_to_date` and `earned_today` are in dollars. ```json Response theme={null} { "publisher_id": "abc-123", "month": "2026-10", "qualifying_requests": 1250000, "rate_microdollars": 400, "tier": 1, "monthly_cap_microdollars": null, "accrued_minimum_microdollars": null, "daily_cap_microdollars": null, "earned_month_to_date": 500.0, "earned_today": 18.4, "qualifying_requests_today": 46000, "month_closed": false, "as_of": "2026-10-28" } ``` ### GET `/publisher-dashboard/proven-ad-value` Get the Publisher Attributable Value (PAV) of one month, by conversion event. Gravity uses the same calculation for the monthly reconciliation. This route is available only when your agreement shows PAV to you. `types` always lists every conversion event of the agreement's rate card, in the agreement's order, including events with a zero value. `provisional` is `true` until the month is over, because conversions can still arrive. ```json Response theme={null} { "publisher_id": "abc-123", "month": "2026-10", "pav_microdollars": 1200000000, "tier": 1, "monthly_cap_microdollars": 5000000000, "provisional": true, "rate_card_version": "v1", "brand_spend_microdollars": 300000000, "types": [ { "type": "Purchase", "value_microdollars": 800000000 }, { "type": "SaaS signup", "value_microdollars": 0 } ] } ``` # Users and segments Source: https://docs.trygravity.com/api-reference/publisher/users-and-segments Rank your users by usage and value, and attach email identities. These routes show how your users differ in usage and value, and where one user ranks. Gravity builds them from the `pub_user_id` values that you send on ad requests. > [!NOTE] > > These routes are available only when Gravity turns on the user value API for your publisher account. Otherwise they return `404`. The date parameters work the same on each route: `days` (1 to 365, default 30), or `start_date` and `end_date` (`YYYY-MM-DD`). A range longer than 365 days returns `400`. `arpu` is on a payout basis. Gravity splits the payout of the range (what `/stats` reports) across users by their share of attributed revenue. These routes return ranks, shares, and averages, not revenue totals. ## GET `/publisher-dashboard/user-segments` Compare groups of the users who were active in the range. **country\_limit** `integer` (default: 20) The number of countries in `by_country`, from 1 to 100. | Field | Description | | - | - | | `usage_segments` | Users ranked by ad requests, in cumulative groups: `top_10_pct`, `top_20_pct`, `top_30_pct`, `top_40_pct`, and `all`. | | `value_segments` | Users ranked by revenue per user: `top_10_pct_by_arpu`, `bottom_10_pct_by_arpu`, and `all`. | | `usage_deciles` / `value_deciles` | The same two rankings in separate 10% groups (`0_10_pct` to `90_100_pct`). Empty when fewer than 10 users were active. | | `by_country` | The number of active users in each country. `Unknown` when the country isn't known. | | `ctr` | Clicks / impressions, as a fraction. | | `revenue_share` | The group's share (0 to 1) of the revenue attributed to active users. | The activity counters are lifetime totals for each user. The range selects which users are included. ```bash Request theme={null} curl "https://platform.trygravity.com/publisher-dashboard/user-segments?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json Response theme={null} { "usage_segments": [ { "segment": "top_10_pct", "users": 120, "requests": 48000, "ads_served": 30000, "impressions": 26000, "clicks": 310, "ctr": 0.0119, "arpu": 1.42, "revenue_share": 0.38 } ], "value_segments": [], "usage_deciles": [], "value_deciles": [], "by_country": [{ "country_code": "US", "users": 640 }], "total_users": 1200, "period_days": 30 } ``` ## GET `/publisher-dashboard/users/{pub_user_id}/segment` Find the groups that one user belongs to, ranked against the same users as `/user-segments`. The response has ranks and groups only, no revenue. **pub\_user\_id** `string` *required* Your ID for the user. With `id_type=email`, send the email address or its SHA-256 hash. **id\_type** `string` (default: pub\_user\_id) `pub_user_id` or `email`. An email lookup works only for users whose email you sent with `/users/identify` or who submitted a lead form. `usage_bucket` is `top_10_pct` to `top_40_pct`, or `below_top_40_pct`. `value_bucket` is `top_10_pct_by_arpu`, `bottom_10_pct_by_arpu`, or `middle`. A percentile is the share of users ranked above this user (`0.0` is the first user). If the user doesn't exist or wasn't active in the range, the response is `404`. ```json Response theme={null} { "pub_user_id": "user-123", "usage_decile": 1, "usage_percentile": 0.04, "usage_bucket": "top_10_pct", "value_decile": 3, "value_percentile": 0.27, "value_bucket": "middle", "total_users": 1200, "period_days": 30 } ``` ## POST `/publisher-dashboard/users/identify` Attach email identities to your users, so you can look them up by email. Gravity attaches an identity only to a user it has already seen. **users** `array` *required* From 1 to 1,000 items. Each item has `pub_user_id` and one of `email` or `hashed_email`. Gravity hashes `email` and doesn't store it. `hashed_email` is the SHA-256 hex digest (64 characters) of the lowercased, trimmed address. A blank `pub_user_id`, an `email` with no `@`, a `hashed_email` that isn't 64 hex characters, or an item with neither field returns `422`. ```bash Request theme={null} curl -X POST https://platform.trygravity.com/publisher-dashboard/users/identify \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{"users": [{"pub_user_id": "user-123", "email": "jane@example.com"}]}' ``` ```json Response theme={null} { "received": 1, "matched": 1, "unmatched_pub_user_ids": [] } ``` ## GET `/publisher-dashboard/arpu-trend` Get the number of active users on each UTC day, and the users who first appeared in the range, grouped by week. `cohort_week` is the Monday that starts the week. ```json Response theme={null} { "daily": [{ "date": "2026-10-20", "active_users": 410 }], "cohorts": [{ "cohort_week": "2026-10-19", "users": 95 }], "period_days": 30 } ``` # Changelog Source: https://docs.trygravity.com/changelog New features and fixes in Gravity for advertisers and publishers, newest first. What's new in Gravity: the dashboard, the APIs, the SDKs, and the Gravity pixel. Each entry says what changed and whether you need to do anything. The newest entry is first. > [!NOTE] > > To upgrade an SDK, install the latest version of the package (for example `npm install @gravity-ai/api@latest`) and deploy your app again. Minor and patch versions in the `1.x` line keep the same public API. **Dashboard features in the Platform API** October 28, 2026 · Advertisers and publishers **More of the dashboard is available with your API key.** The Platform API and the dashboard MCP server now cover more advertiser and publisher dashboard tasks. You can manage campaigns, read reports, and use account features with your account API key. A few tasks, such as adding payment details and approving connections, still need the browser. Related docs: [Advertiser dashboard MCP server](/advertisers/dashboard-mcp) · [Publisher dashboard MCP server](/ai-platforms/dashboard-mcp) **Earnings in the stats endpoint** October 28, 2026 · Publishers **`GET /publisher-dashboard/stats` uses the dashboard's earnings calculation.** The endpoint, and the `publisher_get_stats` MCP tool that calls it, now compute earnings the same way as the **Overview** and **Earnings** pages. If your account has a monthly floor, qualifying-request terms, or launch earnings, the numbers from this endpoint can change to match the dashboard. This changes reported earnings only, not payouts. Related docs: [`GET /publisher-dashboard/stats`](/api-reference/publisher/reports#get-/publisher-dashboard/stats) **Network and Index together** October 2026 · Publishers **Overview and Earnings show Network and Index performance together.** If your account has Index activity, you can pick which source to include. Placement, device, and geography breakdowns still show Network results only. **Block advertiser domains** October 2026 · Publishers **Block advertiser domains in Publisher Settings.** Add domains one at a time or paste a list, then save. Blocking a domain also blocks its subdomains. Blocks reduce the competition for your placements and can lower your revenue. Changes apply after the next refresh of the ad server cache, so they can take a few minutes. **Image ads** October 2026 · @gravity-ai/react 1.3.0, @gravity-ai/ui 0.3.0, @gravity-ai/react-native 0.2.0 **Ads can show a product image.** When an ad has an image, the response includes the `brandImage` field. The SDK then shows an image card, with the image on one side and the text on the other side. An ad without an image shows as the usual text card. Gravity selects the layout on the server, so you do not configure it. ```bash theme={null} npm install @gravity-ai/react@latest # or @gravity-ai/ui@latest, @gravity-ai/react-native@latest ``` If you call the API directly, read `brandImage` and make your own card. To get a test image ad, send `"testAd": true, "alwaysReturn": true, "testAdFormat": "image"`. Related docs: [Image ads](/engine/contextual-ads#image-ads) **Saved report columns** October 20, 2026 · Advertisers **Choose, reorder, and save the columns you use.** In Ads Manager, the Campaigns, Ad groups, and Ads tables now let you pick columns, reorder them, and save column sets. Campaigns and Ad groups can also show metrics for the events you track, and CSV exports use the same columns. Tracked-event columns count attributed events, not every raw pixel event. **Conversion event check for CPA** October 20, 2026 · Advertisers **CPA campaigns need a matching server conversion event before launch.** A live Conversions API event or a verified test event for the selected action unlocks it, so you can confirm tracking before you spend. Pixel activity alone doesn't unlock the action. You can still prepare a draft before the event arrives. Related docs: [Server-side conversions](/advertisers/server-conversions) **One monthly cap for CPA** October 16, 2026 · Advertisers **Every CPA campaign has one monthly cap.** The campaign builder, tables, and details show the same cap. You can set a cap or leave the campaign uncapped, and the dashboard no longer asks for a separate daily budget. **Guided tracking setup** October 14, 2026 · Advertisers **Events Manager guides you through tracking setup.** Choose your website or mobile app, follow the installation steps, then test that customer actions reach Gravity. Shopify setup now separates hosted stores from custom storefronts. **Results by ad group** October 13, 2026 · Advertisers **See Results, cost per result, and revenue for each ad group.** The Ad groups table in Ads Manager and the advertiser API both report them. You can sort the metrics and export them. Conversions without an ad group, such as view-through conversions, still count only at the campaign level. Related docs: [`GET /advertiser-dashboard/ad-group-metrics`](/api-reference/advertiser/reports#get-/advertiser-dashboard/ad-group-metrics) **1.2.0** October 3, 2026 · @gravity-ai/api **More time for an ad response.** `gravityAds()` now waits up to 4 seconds for an ad by default, up from 3 seconds. To change it, set `timeoutMs`. ```bash theme={null} npm install @gravity-ai/api@latest ``` Related docs: [JavaScript SDK](/sdks/javascript) **1.2.1** September 22, 2026 · @gravity-ai/react **Impression tracking survives re-renders.** Ad cards keep their impression tracking active when your chat UI re-renders during a streamed response. Before this fix, an impression could arrive late or not at all. An ad still needs to be 50% visible to count an impression. ```bash theme={null} npm install @gravity-ai/react@latest ``` **1.1.9** July 19, 2026 · @gravity-ai/react **The banner variant does not cut the text.** The `banner` variant now shows the ad on two lines: the brand row on top, and the full `adText` below it. Before this change, the title had too little space and the text stopped in the middle of a word. Version `1.1.8` (July 16) was the first fix: it shortened a long title with an ellipsis and stopped the label and the CTA from overlapping in narrow containers. You do not have to change your code. Upgrade, and the banner shows correctly at narrow widths. ```bash theme={null} npm install @gravity-ai/react@latest ``` Related docs: [Show ads](/ai-platforms/show-ads) **0.1.0** July 6, 2026 · @gravity-ai/ui **Ad layouts from the server.** The new package `@gravity-ai/ui` shows a `renderer_spec`. A `renderer_spec` is a layout description that the engine sends with the ad. Gravity can change the layout without a new deployment of your app. This lets Gravity test designs on surfaces that do not use React, and on pages that your server renders. ```bash theme={null} npm install @gravity-ai/ui ``` This package is optional. If your app uses React, `GravityAd` from `@gravity-ai/react` is the easiest option. For the full guide, see [Server-rendered ads](/sdks/server-rendered-ads). **1.1.8** May 1, 2026 · @gravity-ai/api **New placement types.** The `Placement` type now includes `search_result`, `top_page`, `bottom_page`, `left_page`, `right_page`, and `center_page`. TypeScript now accepts these values when you request ads for page surfaces or search surfaces. ```bash theme={null} npm install @gravity-ai/api@latest ``` Related docs: [Placements](/ai-platforms/placements) **1.1.7** April 22, 2026 · @gravity-ai/api **The SDK reads the pixel values automatically.** When the page loads both the pixel and the SDK, each `gravityContext()` call reads four values from `window.gravityPixel`. You do not have to write code to connect them. | Pixel method | Request field | What it does | | - | - | - | | `getVisitorId()` | `user.gruid` | A stable first-party visitor ID. Gravity uses it for attribution and in the auction. | | `getSessionId()` | `sessionId` (top level) | The pixel session ID. A pixel session ends after 30 minutes with no activity. If you do not send a `sessionId`, the SDK uses this value. | | `getClickId()` | `user.grclid` | The Gravity click ID, when a user comes to your site from a Gravity ad. Gravity uses it for attribution reports. | | `getGraid()` | `user.graid` | The ad identifier of the incoming visit. Gravity uses it for attribution reports. | A value that you send yourself always has priority over the pixel value. Code that sends its own `sessionId` continues to work with no change. **What to do:** upgrade to `1.1.7` and make sure that the pixel is installed ([install guide](/ai-platforms/pixel)). You can then remove your own `sessionId` code if you want. For design tests, see [Experiments](/ai-platforms/experiments). ```bash theme={null} npm install @gravity-ai/api@1.1.7 ``` Related docs: [Pixel](/ai-platforms/pixel) · [Request ads](/ai-platforms/request-ads) · [Experiments](/ai-platforms/experiments) **1.1.6** March 10, 2026 · @gravity-ai/api + @gravity-ai/react **The SDK hashes personal data for attribution.** The SDK now hashes email addresses and phone numbers in the client (normalized, then SHA-256) before it sends them. The SDK also forwards the pixel `gruid`, so Gravity can attribute conversions. The raw values do not leave the client. You do not have to change your integration. This change lets more conversions match on the advertiser side. **1.1.5** February 22, 2026 · @gravity-ai/api + @gravity-ai/react **Tests of ad designs.** The SDK and the engine can now test different ad designs for each request. A design is the set of fields that the server sends and the layout that the client shows. The Gravity team controls these tests. With this change, Gravity can test design variants (for example, different CTA text, or a card compared to a pill layout) and you do not have to deploy new code. For more information, see [Experiments](/ai-platforms/experiments). **1.1.0 to 1.1.4** January 2026 These versions contain bug fixes and small improvements: * Fixed a hardcoded `status: 200` in the `gravityAds` success path. * Fixed the `setAd` hover cleanup and the order of the spread props in `gravityContext`. * Fixed a URL injection problem on Windows, and a bug where a timeout of `0` was read as false. * `openUrl` now ignores `execFile` errors, so that the host app does not crash. * Deprecated the legacy `Client` and `AdParams` API. Use `Gravity` and `gravityContext` instead. If you use `1.1.0` or later, you have these fixes. If you use `1.0.x`, upgrade when you can. None of these fixes is a security fix. **1.0.x (Jan 2026) and earlier** Pre-January 2026 The history before `1.1.x` is on npm: [`@gravity-ai/api` versions](https://www.npmjs.com/package/@gravity-ai/api?activeTab=versions) · [`@gravity-ai/react` versions](https://www.npmjs.com/package/@gravity-ai/react?activeTab=versions). The versions `0.0.x → 0.1.x → 1.0.0` added the first public API, the ad card, the impression logic, the publisher playground, and the first `@gravity-ai/react` templates. If you use a version in this range, upgrade directly to `latest`. ## How versions ship * Each package (`@gravity-ai/api`, `@gravity-ai/react`, `@gravity-ai/ui`, `@gravity-ai/react-native`) has its own version number. Most platforms need `@gravity-ai/api` on the server. `@gravity-ai/react` adds ready-made React components. * The `latest` tag on npm always points to the newest stable release. * Gravity serves the pixel (`gr-pix.js`) from `https://code.trygravity.com/gr-pix.js` and updates it in place. You do not select a pixel version. You always get the current build. ## Get help * **Publisher integration help:** see the [Integration guide](/ai-platforms/integration-guide). * **Advertiser setup:** see the [Advertiser quickstart](/advertisers/quickstart). * **Anything else:** send an email to [support@trygravity.com](mailto:support@trygravity.com). * **Experiments:** see [Experiments](/ai-platforms/experiments). * **Go-live checks:** see [Going live](/ai-platforms/going-live). # Ad Feedback Source: https://docs.trygravity.com/engine/ad-feedback POST /ad-feedback Let users react to ads with a thumbs up or a thumbs down. Send the reaction of a user to an ad: a thumbs up or a thumbs down, with optional reasons. Gravity uses this feedback to make ads more relevant. You can use it to measure user satisfaction. ## Base URL ``` https://platform.trygravity.com ``` ## Headers **X-API-Key** `string` *required* Your Gravity API key. Use the same key that you use for ad requests. > [!WARNING] > > Do not put your API key in browser code. Send feedback from your server. See [Send feedback from your server](#send-feedback-from-your-server). ## Body **sessionId** `string` *required* The session ID. Use the same value that you sent in the ad request. **impUrl** `string` *required* The `impUrl` of the ad, as the ad response gave it. The server gets the tracking IDs of the ad from this URL. You do not need to parse it. **sentiment** `string` *required* The reaction of the user. One of `thumbs_up` or `thumbs_down`. **reasons** `array` Optional. An array of strings that tell why the user reacted. You can send any string. These values are suggestions: | Reason | Use it when | | - | - | | `not_interested` | The user does not care about this product or category. | | `irrelevant` | The ad is not related to the conversation. | | `offensive` | The ad content is offensive. | | `repetitive` | The user saw this ad too many times. | | `misleading` | The ad claims do not agree with the landing page. | | `helpful` | (Thumbs up) The ad was useful or a good match. | **comment** `string` Optional. Free text from the user, at most 1,000 characters. **placement** `string` Optional. The placement type where the ad showed, for example `below_response`. If you do not send it, the server gets it from `impUrl`. **placementId** `string` Optional. Your slot ID for the placement. If you do not send it, the server gets it from `impUrl`. **userId** `string` Optional. Your ID for the end user. **device** `object` Optional. Data about the device. The fields are `ip`, `ua`, `lang`, `os`, `osv`, `make`, `model`, and `type`. All are strings. ```bash theme={null} curl -X POST https://platform.trygravity.com/ad-feedback \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "sessionId": "sess_abc123", "impUrl": "https://api.trygravity.ai/ack?p=ENCRYPTED_TOKEN_FROM_AD_RESPONSE", "sentiment": "thumbs_down", "reasons": ["not_interested", "repetitive"] }' ``` ## Response **`200`**: Gravity recorded the feedback. ```json theme={null} { "status": "ok", "feedbackId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } ``` ## Errors | Status | Cause | What to do | | - | - | - | | `401` | The API key is not valid. | Check the key. | | `422` | The `X-API-Key` header is missing, a field is not valid, or the server cannot read `impUrl`. | Send the exact `impUrl` from the ad response, and check the body. | | `429` | Too many requests. The limits are 30 requests for each session and 60 requests for each client IP address, in each minute. | Wait for the number of seconds in the `Retry-After` header. | | `503` | The server could not record the feedback. | Try again. | Use the status code and the `Retry-After` header in your code. Do not use the `detail` text, because it can change. ## Official SDKs The iOS components (`SpecAdView`, `GravityAdView`) and the Android Compose components (`SpecAd`, `GravityAd`) show thumbs-up and thumbs-down buttons under each ad. A tap sends the feedback and hides the buttons. A thumbs down sends the reason `not_interested`. To hide the buttons, set `showFeedback` to `false`. See [iOS SDK](/sdks/ios) and [Android SDK](/sdks/android). The iOS and Android SDKs also have a `sendFeedback()` method for custom UIs. ## Send feedback from your server Your API key must stay on your server. For a web app, make a small route on your server that forwards feedback to Gravity. 1. Keep the ad object that your server returned, and keep the `sessionId`. 2. Show a thumbs-up and a thumbs-down button after the ad becomes visible. 3. When the user taps a button, send the reaction to your route. 4. In your route, forward the body to Gravity with your API key. Client: ```tsx theme={null} function AdFeedback({ ad, sessionId }) { const [sent, setSent] = useState(false); const sendFeedback = (sentiment, reasons) => { setSent(true); fetch('/api/ad-feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, impUrl: ad.impUrl, sentiment, reasons }), }).catch(() => {}); }; if (sent) return Thanks for the feedback; return (
); } ``` Server (Express): ```ts theme={null} app.post('/api/ad-feedback', async (req, res) => { const { sessionId, impUrl, sentiment, reasons } = req.body; const response = await fetch('https://platform.trygravity.com/ad-feedback', { method: 'POST', headers: { 'X-API-Key': process.env.GRAVITY_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ sessionId, impUrl, sentiment, reasons }), }); res.status(response.status).end(); }); ``` ## Recommendations **Make feedback one tap.** Show small buttons after the ad becomes visible. Do not use a modal dialog. **Ask for reasons after a thumbs down.** Show a short list of chips or checkboxes, for example `not_interested`, `irrelevant`, `repetitive`, and `offensive`. Send the reasons that the user selects in the `reasons` array. **Do not block the user.** Send feedback in the background. If the request fails, ignore the error. A feedback tap must not count as an ad click. **Send `impUrl` as it is.** Keep the ad object, and send `ad.impUrl` with the feedback. The server reads the tracking IDs from it. Do not parse or decode it in your client. ## Next steps > **[Show ads](/ai-platforms/show-ads)** > > Show ads with the ready-made React component or with your own UI. > **[Contextual ads endpoint](/engine/contextual-ads)** > > The full request and response reference for `POST /api/v1/ad`. # Contextual Ads Source: https://docs.trygravity.com/engine/contextual-ads POST /api/v1/ad Get ads that match the conversation context. Send the recent conversation messages and get back ads that match the conversation, with ad text that the engine writes for it. For the terms on this page, see [Core concepts](/introduction/concepts). ## Headers **Authorization** `string` *required* Your Gravity API key in the format `Bearer YOUR_API_KEY`. **Sec-GPC** `string` Optional. Browsers with Global Privacy Control turned on send `Sec-GPC: 1`. When the value is `1`, the engine serves contextual ads only, the same as `consent.gpc: true`. ## Body **messages** `array` *required* The conversation history: an array of `{role, content}` objects. Both values are strings. The engine uses the most recent messages to match ads. This field is required by default. **sessionId** `string` *required* The ID of the conversation. Gravity uses it for frequency limits, experiment groups, and reports. Use one stable value for each chat thread. If the [Gravity pixel](/ai-platforms/pixel) is on the page and you use `@gravity-ai/api` 1.1.7 or later, `gravityContext()` uses the pixel session ID (`gr_sess_` prefix) when you do not pass a `sessionId`. The pixel session ends after 30 minutes with no activity, or after 1 day. To use your own session scope, pass your own `sessionId`. **placements** `array` *required* The ad slots to fill: 1 to 10 placements in each request. The response has at most one ad for each placement. **placement object** **placement** `string` *required* The slot type. One of `above_response`, `below_response`, `inline_response`, `left_response`, `right_response`, `search_result`, `top_page`, `bottom_page`, `center_page`, `left_page`, `right_page`. **placement\_id** `string` *required* Your ID for this slot. The dashboard uses it to report on the slot. Each `placement_id` must be unique in one request. The engine rejects a request with duplicate IDs. **max\_ad\_text\_chars** `number` The maximum length of the ad text for this slot, from 50 to 5000 characters. This value replaces the length that the dashboard sets for the placement. **user** `object` Optional. Data about the user for targeting and attribution. All fields are optional. The engine accepts other fields too and stores them as request context. **user fields** **id** `string` Your stable ID for the user. Gravity uses it for frequency limits and attribution. The JavaScript and Python SDKs send it from `user.userId`. The Android and iOS SDKs take `userId` on `getAds()`. The Python, Android, and iOS SDKs send `"anonymous"` when you do not give one. For signed-out users, send a stable anonymous install or device ID. Gravity leaves the shared value `"anonymous"` out of per-user analytics. **hashed\_email** `string` The SHA-256 hash of the email after `strip()` and `lower()`. This is the main email field. It helps attribution; see [Data quality](/ai-platforms/request-ads#data-quality). The engine also accepts `email_hash` as an older name for this field. **hashed\_phone** `string` The SHA-256 hash of the phone number with digits only. **email** `string` The raw email. The engine hashes it and does not store the raw value. Send `hashed_email` when you can. **phone** `string` The raw phone number. The engine hashes it. Send `hashed_phone` when you can. **tags** `object` Optional. A flat object of up to 10 tags that group your users in your own analytics (**Audience → Users**), for example `{ "plan": "free", "utm_source": "tiktok" }`. Tags are never used for ad targeting and never shared with advertisers. See [Understand what your users earn you](/ai-platforms/user-earnings). * **Keys** are lowercased and must be lowercase letters, digits and underscores, 1 to 32 characters. Other keys are dropped. * **Values** can be strings, numbers or booleans. They're stored as strings and trimmed to 64 characters. Empty values, nested objects and lists are dropped. * **More than 10 valid keys:** the first 10 in sorted order are kept. * **Updates:** resend the complete tag map on every request. The latest pending map per UTC day wins; omitted keys from earlier requests that day aren't preserved. Persisted maps merge across completed days, with newer values winning per key. * Groups with fewer than 50 users aren't shown in the dashboard. Send `plan` (`free` or `paid`) as your first tag. It shows what your free users earn. **usage** `object` Optional. Usage for one model call behind a reply, not cumulative totals for the conversation, session or user. It lets the dashboard show model cost next to earnings. Malformed values are ignored without failing the request. See [Report model usage](/ai-platforms/request-ads#report-model-usage). **usage fields** **model** `string` The model ID as the provider names it, for example `gpt-4.1-mini`. Sending only `model`, with no token counts, still lets the dashboard break earnings down by model. **inputTokens** `integer` Tokens read by this one model call, including its system prompt, conversation history and other context. A non-negative integer, not a cumulative conversation or user total. **outputTokens** `integer` Tokens generated by this one model call. A non-negative integer, not a cumulative conversation or user total. **cachedInputTokens** `integer` Optional cache-read tokens, a subset of `inputTokens`, not extra input. `cached_input_tokens` is accepted, as are OpenAI Responses' `input_tokens_details.cached_tokens` and Chat Completions' `prompt_tokens_details.cached_tokens`. Input/prompt totals from OpenAI already include cached tokens; don't add them again. Malformed values are ignored and accepted counts are bounded by total input. A valid `cost` still overrides the estimate. **cost** `number` Optional, and it only makes the cost figure exact. The USD you paid for the model call behind this reply, from 0 to 100. When present, the dashboard uses it instead of the list-price estimate; when absent, cost is estimated from the model maker's public list price. `0` means a free call, such as a self-hosted model. Invalid values are ignored without failing the request. `cost_usd` is accepted as an alias. OpenRouter already returns this as `usage.cost`. The snake\_case keys from provider usage objects also work: `input_tokens` / `output_tokens` and `prompt_tokens` / `completion_tokens`. Report one model response per ad request: the reply the ad is shown with, or the previous reply if you request the ad before the reply finishes. See [Understand what your users earn you](/ai-platforms/user-earnings). **device** `object` Data about the end user's device. Send `ip` and `ua` in each request. Gravity uses them for fraud and bot detection, and for the Device and Geography reports in your dashboard. The browser SDK collects them through `gravityContext()`. > [!WARNING] > > If you call the API from your server, forward the `device` object that the client collected. If you do not, the request carries the IP address and User-Agent of your server, not of the end user. **device fields** **ua** `string` The User-Agent of the end user's browser or app. It controls fraud detection and the split between mobile, tablet, and desktop. **ip** `string` The IP address of the end user. It controls fraud detection and geo-targeting. The server SDKs add it from the incoming request. If you call the API directly, set it yourself. **country** `string` Optional. A two-letter ISO country code. Gravity also gets the country from `ip`. **os** `string` The operating system, for example `iOS`, `Android`, or `Windows`. **ifa** `string` The mobile advertising ID (IDFA or GAID), for apps. **id** `string` Your stable ID for the device. The engine also reads `timezone`, `locale`, `browser`, `device_model`, `region`, and `city`, and stores other fields as request context. A native app or other client with no browser must send its real client User-Agent (for example `CFNetwork/Darwin`, `okhttp`, or `ktor-client`) and the end user's IP address. See [Request ads → Device signals](/ai-platforms/request-ads#device-signals). **relevancy** `number` Optional. The minimum relevancy, from 0.0 to 1.0. If you do not send it, the engine uses the baseline value for your account. The SDKs send `0.2` by default. **excludedTopics** `array` Optional. Topics that ads must not match, for example `["politics"]`. **testAd** `boolean` (default: false) Optional. When `true`, the engine returns test ads and does not bill or count them. The SDKs send the opposite of their `production` setting. **alwaysReturn** `boolean` (default: false) Optional, for test requests only (`testAd: true`). When `true`, the engine returns a default test ad instead of a `204`. Use it to build your UI. **testAdFormat** `string` Optional, for test requests only (`testAd: true`). Makes the engine return a sample of one ad format, so that you can build and check your UI. | Value | Returns | | - | - | | `"image"` | A sample ad with `brandImage` and an image layout in `renderer_spec`. You get this even if your client does not draw server-rendered designs. | | `"lead_form"` | A sample ad with a `leadForm` object. Gravity does not store the leads from this sample form. | | `"iframe"` | A sample iframe ad. It needs `supportsFrames: true`. | Send `alwaysReturn: true` too to get an ad in each response. With `"image"`, send `adLayout` (`"image_left"`, `"image_right"`, or `"image_top"`) to see one layout. The default is `"image_right"`. Live requests ignore `adLayout`, because Gravity selects the layout for each publisher. **supportsSpec** `boolean` Optional. Tells the engine if your client can draw server-rendered designs (`renderer_spec`). Send `false` if you draw ads with your own code. If you do not send this field, a `specCapabilities` value means `true`. The official SDKs set this field for you. **specCapabilities** `string` Optional. The type of server-rendered design that your client can draw. The official SDKs set this field for you. The engine ignores values that it does not know. | Value | Meaning | | - | - | | `"psp1"` | The native iOS, Android, or React Native renderer. | | `"web1"` | The web renderer. | **supportsFrames** `boolean` Optional. Send `true` if your client can show iframe ads. The engine sends iframe ads only to clients that send `true` and only to publishers that Gravity turned on for iframe ads. **consent** `object` Optional. The privacy and consent choices of the end user. If you do not send this object, the engine works as usual. | Field | Type | Description | | - | - | - | | `gdprApplies` | `boolean` | `true` when GDPR applies to the user (EU, EEA, or UK). | | `tcfString` | `string` | The IAB TCF consent string from your consent management platform (CMP). | | `usPrivacy` | `string` | The US privacy (CCPA/CPRA) string, for example `"1YNN"`. | | `gpc` | `boolean` | `true` when the user's browser or device sends Global Privacy Control. | When `gdprApplies` is `true` and the request has no valid `tcfString`, the engine serves **contextual-only** ads. It does not process user identifiers for that request. The same occurs when `usPrivacy` opts out of sale or when `gpc` is `true`. Contextual matching needs no personal data, so ads still serve. ```bash theme={null} curl -X POST https://server.trygravity.com/api/v1/ad \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ {"role": "user", "content": "How do I set up a PostgreSQL database?"}, {"role": "assistant", "content": "Here are the steps to set up PostgreSQL..."} ], "sessionId": "sess_abc123", "placements": [ {"placement": "below_response", "placement_id": "main"} ], "user": {"id": "user_789", "tags": {"plan": "free"}}, "usage": { "model": "gpt-4.1-mini", "prompt_tokens": 1830, "prompt_tokens_details": {"cached_tokens": 1024}, "completion_tokens": 412 }, "device": { "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...", "ip": "203.0.113.42" }, "testAd": true }' ``` ## Response When the engine finds a match, the response is HTTP `200` with a **JSON array** of ad objects. The array has at most one ad for each placement in the request. ```json theme={null} [ { "adText": "Serverless Postgres that scales to zero. Start free.", "title": "Example Serverless Postgres", "brandName": "Example DB", "cta": "Try it free", "url": "https://example.com", "favicon": "https://example.com/favicon.ico", "brandImage": "", "clickUrl": "https://api.trygravity.ai/track/click?p=...", "impUrl": "https://api.trygravity.ai/ack?p=...", "placement": "below_response", "placement_id": "main" } ] ``` > [!NOTE] > > The SDKs put this array in a result object: `{ ads, status, elapsed }` in JavaScript and `AdResult(ads, status, elapsed_ms, ...)` in Python. The API response does not contain this object. ### Ad object | Field | Type | Description | | - | - | - | | `adText` | `string` | The ad text, written for this conversation. | | `title` | `string` | The product or campaign title. | | `brandName` | `string` | The advertiser's brand name. | | `cta` | `string` | The call-to-action text. | | `url` | `string` | The landing page URL. Do not use it for links. | | `favicon` | `string` | The URL of the brand icon. | | `brandImage` | `string` | The URL of the product image, or `""` when the ad has no image. Always present. See [Image ads](#image-ads). | | `clickUrl` | `string` | The tracked click URL. Use it for all links. | | `shortClickUrl` | `string` | A short form of `clickUrl` for surfaces that show the URL as text, for example SMS. It has the same tracking. | | `impUrl` | `string` | The impression URL. Load it once when the ad becomes visible. | | `placement` | `string` | The placement type that this ad fills. | | `placement_id` | `string` | Your slot ID from the request. | | `campaignId` | `string` | The campaign ID. Not present by default. Lead-form ads always include it. | | `renderer_spec` | `object` | The server-rendered design for the ad. See [Server-rendered ads](/sdks/server-rendered-ads). | | `render` | `object` | Tells the SDK which mode to use to draw the ad. The official SDKs read it for you. | | `leadForm` | `object` | The lead form, when the campaign uses one. The official SDKs draw it for you. | The response does not include fields that have no value. > [!WARNING] > > Always use `clickUrl` for ad links, not `url`. Load `impUrl` when the ad becomes visible. If you do not, Gravity cannot count the click or the impression, and you do not get paid for it. ### `renderer_spec` Gravity sets the server-rendered design for each placement. When a design is available for your client, the response includes `renderer_spec`. If not, the SDK uses its built-in card. The official SDKs handle this for you. To get a custom placement design, contact [support@trygravity.com](mailto:support@trygravity.com). ### Image ads When the campaign has a product image, the ad includes `brandImage` (the image URL) and a `renderer_spec` with an image card. The image card shows the image on one side and the text on the other side. An ad with no image has `brandImage: ""` and shows as the usual text card. * **Official SDKs** (`@gravity-ai/react` 1.3.0 and later, `@gravity-ai/ui` 0.3.0 and later, `@gravity-ai/react-native` 0.2.0 and later) draw the image card for you. Older versions ignore it and show the text card. * **Direct API integrations** can ignore `renderer_spec` and draw their own design from `brandImage`, `title`, `adText`, `cta`, and `clickUrl`. * Gravity selects the layout (`image_left`, `image_right`, or `image_top`) for each publisher. You do not need to send a value. To get an image ad while you build your UI, send these fields in a request: ```json theme={null} { "testAd": true, "alwaysReturn": true, "testAdFormat": "image" } ``` ### Experiment fields When an [experiment](/ai-platforms/experiments) is active, the ad object also includes these fields: | Field | Type | Description | | - | - | - | | `variant` | `string` | The label of the experiment arm. | | `experiment_id` | `string` | The experiment ID. | | `composition_id` | `string` | The ID of this render. It is unique for each impression. | | `renderer_key` | `string` | The renderer to use. | | `composition` | `object` | The tokens and properties for the renderer. | The `impUrl` and `clickUrl` values already contain the experiment data, so the reports join the data for you. ## No ad available The response is HTTP `204 No Content` with an empty body in these cases: * No ad matches the conversation. * The engine identifies the request as a bot. * The request takes more than 4 seconds. * The engine has an error that it cannot recover from. There is no JSON body. Hide the ad slot in your UI. ## Errors | Status | Cause | Body | | - | - | - | | `400` | A required field is missing, for example `messages`. | `{"detail": {"errors": ["Field 'messages' is required"]}}` | | `401` | The API key is missing or not valid. | Empty, or `{"detail": "Not authenticated"}` | | `413` | The request body is larger than 1 MB. | | | `422` | The body does not match the schema (for example, `placements` is missing or has more than 10 items). | `{"detail": [...]}` with one item for each problem | | `422` | All placements in the request are inactive. | `{"error": "No active placements found. ..."}` | | `429` | Too many requests. | `{"detail": "Too many requests"}` and a `Retry-After` header | To fix a `400`, send the conversation `messages`. See [Request ads](/ai-platforms/request-ads#what’s-in-the-request). To recover from a `429`, wait for the number of seconds in `Retry-After`, then try again. # Ad serving API Source: https://docs.trygravity.com/engine/introduction The public Gravity ad serving API: the endpoints, the hosts, authentication, and common errors. Use the ad serving API to request ads and to send user feedback on ads. For the terms on this page, see [Core concepts](/introduction/concepts). To manage an account with code, see the [Advertiser Dashboard API](/api-reference/advertiser/overview) and the [Publisher Dashboard API](/api-reference/publisher/overview). To send conversions from your backend, see the [Conversions API](/advertisers/server-conversions) (`POST /gateway/events`). ## Endpoints | Endpoint | Host | Purpose | | - | - | - | | `POST /api/v1/ad` | `server.trygravity.com` | Get ads that match the conversation. This is the main endpoint. | | `POST /ad-feedback` | `platform.trygravity.com` | Send the reaction of a user to an ad (thumbs up or thumbs down). | | `GET /health` | `server.trygravity.com` | Check that the ad server is available. | | `POST /api/v1/index/session/messages` | `server.trygravity.com` | Send conversation messages to the Gravity Index. See [Conversation context](/gravity-index/conversation-context). | The SDKs call `/api/v1/ad` for you. They prepare the request context, record impressions, and return an empty result when a request fails. ## Base URL ``` https://server.trygravity.com ``` > [!NOTE] > > `server.trygravity.com` and `server.trygravity.ai` point to the same load balancer and serve the same engine, so either host works. The SDKs and Gravity's own services use `https://server.trygravity.ai` by default. The examples on this page use `.com`. ## Authentication Each endpoint needs your Gravity API key. Get the key from your [dashboard](https://app.trygravity.com) under **Settings → Platform Settings** (AI platforms) or **Settings → Organization** (advertisers). > [!WARNING] > > Keep your API key on your server. Do not put it in client code or in a public repository. For `POST /api/v1/ad`, send the key as a Bearer token: ```bash theme={null} curl -X POST https://server.trygravity.com/api/v1/ad \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [{"role": "user", "content": "How do I set up a PostgreSQL database?"}], "sessionId": "sess_abc123", "placements": [{"placement": "below_response", "placement_id": "main"}], "testAd": true }' ``` For `POST /ad-feedback`, send the key in the `X-API-Key` header. See [Ad feedback](/engine/ad-feedback). ## Common errors | Status | Meaning | What to do | | - | - | - | | `204` | No ad. The request had no match, took more than 4 seconds, or failed on the server. The body is empty. | Hide the ad slot. | | `401` | The API key is missing or not valid. | Check the `Authorization` header and the key. | | `413` | The request body is larger than 1 MB. | Send fewer or shorter messages. | | `422` | The request body does not match the schema. The `detail` field names each problem. | Fix the fields that `detail` names. | | `429` | Too many requests. | Wait for the number of seconds in the `Retry-After` header, then try again. | Each endpoint page lists its own errors. ## Health check ```bash theme={null} curl https://server.trygravity.com/health ``` When the server is ready, the response is `200`: ```json theme={null} {"status": "ok", "ready": true, "release_sha": "221d530..."} ``` While the server starts, the response is `503` with `{"status": "starting", "ready": false}`. Use this endpoint for uptime checks. Rate limits do not apply to it. ## Next steps > **[Contextual ads endpoint](/engine/contextual-ads)** > > The full request and response reference for `POST /api/v1/ad`. > **[Ad feedback](/engine/ad-feedback)** > > Let users react to ads with a thumbs up or a thumbs down. # CLI Source: https://docs.trygravity.com/gravity-index/cli Search the Gravity Index from the terminal. The `gravity-index` CLI lets you search the Gravity Index from the command line. The CLI is in the Gravity repository and is not on a package registry. Use it only if you have repository access. For public agent integrations, use the [npm MCP server](/gravity-index/mcp). ## Before you start * Get access to the Gravity repository and clone it. * Install Python 3.9 or later. * Get a publisher API key. Every command needs it. ## Install 1. Go to your local copy of the Gravity repository. ```bash theme={null} cd gravity ``` 2. Copy the script to a folder on your `PATH`. ```bash theme={null} mkdir -p ~/.local/bin cp index/cli/gravity-index ~/.local/bin/gravity-index chmod +x ~/.local/bin/gravity-index ``` 3. Make sure that `~/.local/bin` is on your `PATH`. Add `export PATH="$HOME/.local/bin:$PATH"` to your shell startup file. 4. Install `httpx`. ```bash theme={null} pip install --user httpx ``` 5. Set your publisher API key. ```bash theme={null} export GRAVITY_PUBLISHER_KEY=YOUR_API_KEY ``` 6. Run a test search. ```bash theme={null} gravity-index search "auth for Next.js" ``` You see a recommendation, the reasons for it, and a setup link. For other interfaces (MCP server and REST API), see [Install](/gravity-index/install). ## Environment variables | Variable | Required | Description | | - | - | - | | `GRAVITY_PUBLISHER_KEY` | Yes | Your publisher API key. Gravity uses it to attribute searches and integrations to your platform. | | `GRAVITY_INDEX_URL` | No | The API base URL. The default is `https://index.trygravity.com`. | | `GRAVITY_INDEX_MONETIZATION_MODE` | No | Set to `boost_cpa` for relevance-first results that give a small boost to services with an active CPA campaign. The CLI never prints prices or payouts. | ## Commands ### Search ```bash theme={null} gravity-index search "I need a serverless database" ``` The CLI prints the recommendation, the reasons, the setup steps, the environment variables, a tracked setup link, the `grclid`, and the `search_id`. To send identity fields for attribution, add flags: ```bash theme={null} gravity-index search "I need a serverless database" \ --external-session-id session_123 \ --external-user-id-hash sha256_user_id \ --email-hash sha256_email \ --metadata-json '{"surface":"chat"}' ``` | Flag | Description | | - | - | | `--follow-up`, `-f` | The `search_id` of an earlier search. Use it for a follow-up question. | | `--external-session-id` | Your session ID. | | `--external-user-id` | A raw user ID. The API stores only a SHA-256 hash of it. | | `--external-user-id-hash` | A hash of your user ID. | | `--email-hash` | The SHA-256 hash of the normalized user email. | | `--phone-hash` | The SHA-256 hash of the user phone number (digits only). | | `--metadata-json` | A JSON object to store with the search. | ### Follow-up ```bash theme={null} gravity-index search "does it support branching?" --follow-up SEARCH_ID ``` Replace `SEARCH_ID` with the `search_id` from the earlier search. ### Browse ```bash theme={null} # All services gravity-index browse # Services in one category gravity-index browse --category Database # Services that contain a keyword gravity-index browse "vector" ``` ### Service details ```bash theme={null} gravity-index info supabase ``` ## Troubleshoot | Problem | Cause and recovery | | - | - | | `Install httpx: pip install httpx` | The `httpx` package is not installed. Run `pip install --user httpx`. | | `Error: {"detail":"platform_api_key required"}` | `GRAVITY_PUBLISHER_KEY` is not set. Export it in your shell. | | `Error: {"detail":"valid platform_api_key required"}` | The key is not valid. Make sure that you copied the full key. | | `Service 'NAME' not found` | No service has that slug. Run `gravity-index browse` to find the correct slug. | | `command not found: gravity-index` | `~/.local/bin` is not on your `PATH`. Do step 3 again, then open a new terminal. | # Conversation context Source: https://docs.trygravity.com/gravity-index/conversation-context Send your session ID, and the Index recommends from the full conversation, not only from the current query. This page tells you how to give the Index the context of a conversation. With context, the Index can recommend from the full conversation, not only from the current query. ## Overview Send your own stable conversation ID as `external_session_id` on every `/search` call. If Gravity has a current summary of that conversation, the Index uses it as private context. Your `query` stays the current task. The summary tells the Index what the user tried to do before. You do not send earlier turns in the search request. ```json theme={null} { "query": "Recommend a hosted database for this application.", "external_session_id": "chat_8f3a" } ``` If there is no summary, `/search` uses only the query. A summary expires two hours after the last message in the session. The response never contains the summary. Gravity keeps each summary in your publisher account scope. Gravity stores it as a summary embedding and in the Gravity data warehouse for ranking and reports. Gravity does not keep the raw turns. ## Before you start * Get a publisher API key for an approved publisher account. The message endpoint returns `403` for accounts that are not approved. * Select a stable session ID for each conversation. Use the same value on every call. ## Send the conversation to Gravity Send each user turn and each assistant turn to the message endpoint. Use your publisher API key as a Bearer token. This endpoint does not serve an ad or record an impression. It is also separate from the `sessionId` that you send on [`/api/v1/ad`](/ai-platforms/request-ads). Index summaries belong to your publisher account. Gravity builds them only from the messages that you send to this endpoint. ```bash theme={null} curl -X POST https://server.trygravity.com/api/v1/index/session/messages \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -d '{ "session_id": "chat_8f3a", "message": { "id": "msg_0021", "role": "user", "content": "I need a hosted Postgres database for my Next.js app." } }' ``` The response is `202 Accepted` when Gravity has queued the message: ```json theme={null} { "status": "accepted", "session_id": "chat_8f3a", "message_id": "msg_0021" } ``` ### Request fields **session\_id** `string` *required* Your conversation ID, 1 to 256 characters. Send the same value as `external_session_id` on `/search`. If you also call `/shop/search`, use 200 characters or fewer, because `/shop/search` accepts only 200. **message.id** `string` *required* A stable ID for this turn, 1 to 200 characters. Gravity uses it to remove duplicates, so a retry with the same ID is safe. **message.role** `string` *required* `user` or `assistant`. **message.content** `string` *required* The text of the turn, 1 to 50,000 characters. ### Responses | Status | Description | | - | - | | `202` | Gravity queued the message. | | `204` | The request had the `Sec-GPC: 1` header. Gravity stored nothing. | | `401` | The key is not valid. | | `403` `publisher_not_approved` | The publisher account is not approved. | | `422` | A field is not there or is out of range. | | `503` `conversation_ingestion_unavailable` | Gravity did not accept the message. Send it again with the same `message.id`. | If a user message looks like it comes from a minor, Gravity does not store messages from that session. The response is still `202`. ## Search with only the session Sometimes the agent has no query of its own and wants to know what fits the conversation. In that case, call `POST https://index.trygravity.com/session/search` with the session ID and your `X-API-Key` header. ```bash theme={null} curl -X POST https://index.trygravity.com/session/search \ -H 'Content-Type: application/json' \ -H 'X-API-Key: YOUR_API_KEY' \ -d '{ "session_id": "chat_8f3a", "max_options": 5 }' ``` The Index uses the conversation summary as the query. The request also accepts the identity fields of `/search` (`external_user_id`, `external_user_id_hash`, `hashed_email`, `phone_hash`, `metadata`) and `monetization`. `max_options` is 1 to 10 (default 5). The response has the same shape as the [`/search` response](/gravity-index/search), with two changes: it has no `query` field, and it has a `session_id` field. ## Troubleshoot | Problem | Cause and recovery | | - | - | | `/session/search` returns `409 session context is not ready` | Gravity has no summary for this session yet, or the summary expired. Send more turns, then try again after Gravity processes the next turn. | | `/session/search` returns `503` | Session search is not available now. Try again later, or call `/search` with a query. | | `/search` results do not use the conversation | Make sure that `external_session_id` on `/search` is the same value as `session_id` on the message endpoint, and that you use the same publisher key for both. | # For Service Providers Source: https://docs.trygravity.com/gravity-index/for-service-providers List your service in the Gravity Index and pay only for confirmed signups from AI agents. This page is for companies that want AI agents to recommend their service. The Gravity Index is a recommendation API for AI agents and platforms. Coding agents in Cursor, Claude Desktop, and Claude Code use it, and so do custom agents and chat products. When an agent recommends your service, the user gets the reasons and a tracked link to your signup page. ## Why list your service in the Gravity Index > **Distribution through agents** > > Agents call `gravity_index_search` through the Gravity Index MCP server. Your service can show when a developer asks an agent for "a database" or "payments for Next.js". > **Recommended in context** > > Platforms can share a conversation summary with the Index. The Index then recommends you when the user has the problem that you solve, not only when a keyword matches. > **CPA: pay only for results** > > There is no CPC and no CPM. You set a cost per acquisition (CPA). You pay only after a confirmed conversion or a completed integration. ## How it works **1. You get listed** Send Gravity your service details (slug, name, category, description, tags) and the URL of your signup page. Gravity reviews the details and adds the service to the catalog. **2. An agent finds you** An agent calls `gravity_index_search` with a request, for example "managed Postgres with branching". The Index can recommend you and give the reasons. **3. The user clicks** The response has the reasons, a `click_url`, and a `grclid` (Gravity click ID). The link sends the user to your signup page and records attribution. A click alone does not create a conversion or a CPA charge. **4. You get a signup, and Gravity gets the CPA** The user signs up on your platform. You confirm the signup through your conversion feed. If your listing has a CPA, the confirmed conversion creates the CPA charge. ## Costs Each listing has a `default_cpa_cents` value. This is the amount that Gravity charges for each confirmed conversion. A publisher can turn on a private setting that gives a small boost to services with an active CPA campaign. Relevance stays first. The output that agents see never shows prices or payouts. ## Improve your listing After Gravity lists your service, three things have the most effect: 1. **A short, clear description**: one or two sentences on what you do and who it is for. The Index reads it to decide if you fit the user's problem. 2. **The correct category and tags**: they decide the searches for which you are a candidate. 3. **A signup page that matches the description**: the user comes from a recommendation that told them why you fit. The page must confirm that fast. ## Machine-payable checkout (MPP) An agent can buy your product directly if you accept payments through the [Machine Payments Protocol](https://mpp.dev) with a Stripe [Shared Payment Token](https://docs.stripe.com/agentic-commerce/concepts/shared-payment-tokens). Give Gravity your payment link, and the Index returns it with your listing. The agent pays you directly with a one-time Link credential (`link-cli mpp pay`). Gravity is never in the payment path. ### What to send For each listing, send one value: | Field | Example | Notes | | - | - | - | | Payment link | `https://api.acme.dev/mpp/pro-plan` | One HTTPS URL for each product or plan, on a public host. A plain `GET` must return `402` with a `WWW-Authenticate: Payment` header. The price is in the challenge, and the request needs no body. | ### Add the payment link in the dashboard 1. Open your advertiser dashboard. 2. In the sidebar, click **Index**. 3. Click the row of the product. 4. Open **Let AI agents pay for this product**. 5. Paste the URL into **Payment link for agents**. 6. Save. When you save, Gravity gets the link one time. It reads your Stripe `profile_` network ID from the `402` challenge (`method="stripe"`, `methodDetails.networkId`), so you do not type it. If the link does not return `402` at that time, Gravity still stores it. Then `network_id` is `null`, and agents read the ID from your challenge when they pay. The price stays on your side. The agent reads it from the challenge, so you can change it at any time. The product must be yours. Gravity rejects a link for a listing that a different advertiser manages. ### Create the payment link Stripe does not create an MPP endpoint for each Stripe Product or Price. You create a route on your own server. Protect it with [`mppx`](https://mpp.dev/guides/accept-card-payments) and your Stripe account. Then paste the URL of the deployed route into the dashboard. 1. Turn on [machine payments](https://docs.stripe.com/payments/machine) for your Stripe account. Copy your Business Network profile ID (`profile_`) from [Account > Profile](https://docs.stripe.com/get-started/account/profile). 2. Run `npm install mppx stripe`. Then put one endpoint for each plan behind `mppx`: ```ts theme={null} import Stripe from 'stripe' import { Mppx, stripe } from 'mppx/server' const mppx = Mppx.create({ methods: [stripe.spt({ client: new Stripe(process.env.STRIPE_SECRET_KEY!), networkId: process.env.STRIPE_NETWORK_ID!, // profile_... paymentMethodTypes: ['card'], })], }) export async function handler(request: Request) { const result = await mppx.charge({ amount: '2900', currency: 'usd', decimals: 2, description: 'Acme Pro plan', })(request) if (result.status === 402) return result.challenge // the agent gets the 402 // payment verified: provision the plan return result.withReceipt(Response.json({ api_key: '...' })) } ``` 3. Deploy the route. Its URL (for example `https://api.acme.dev/mpp/pro-plan`) is your payment link. 4. Test the link: ```bash theme={null} curl -i https://api.acme.dev/mpp/pro-plan ``` The response is `402`, with a `WWW-Authenticate: Payment` header that contains `method="stripe"`. If you already sell over MPP, send Gravity the URL that you give to other agents. ### What agents get When the recommended listing has a payment link, `POST /search` returns it like this (shortened): ```json theme={null} { "recommendation": { "slug": "acme/pro-plan", "checkout": { "protocol": "mpp", "endpoint": "https://api.acme.dev/mpp/pro-plan?grclid=2f1e0e0d-b6be-4621-b67a-be4ebfb921aa", "network_id": "profile_EXAMPLE", "attribution": { "click_id_tag": "grclid", "click_id_value": "2f1e0e0d-b6be-4621-b67a-be4ebfb921aa" } } }, "click_url": "https://index.trygravity.com/go/2f1e0e0d-b6be-4621-b67a-be4ebfb921aa" } ``` If your URL has a `{grclid}` macro, Gravity puts the click ID there. Gravity also always adds `grclid` as a query parameter. Listings without a payment link return `checkout: null` and the usual `click_url`. ### Confirm the conversion An MPP purchase does not go through the `click_url` redirect. The `grclid` in the query string of your endpoint connects the sale to the recommendation. When the charge is successful, send it to Gravity from your server as a [CAPI](/advertisers/server-conversions) `Purchase` event, with `user_data.grclid` set to that value. Use CAPI for these sales, not the Stripe [connector](/advertisers/integrations#stripe). The connector reads `checkout.session.completed` and `invoice.paid`. An MPP charge is a PaymentIntent that comes from the Shared Payment Token, and the connector does not read it. For your own reconciliation, you can also copy `grclid` into the `metadata` of the PaymentIntent. > [!WARNING] > > Do not send Gravity Shared Payment Tokens, Link credentials, or Stripe secret keys. Gravity does not accept them. ## Badge Gravity gives you an "Available via Gravity Index" badge for your docs. A link back to Gravity is optional. ## Get listed Send an email to `index@trygravity.ai`. # Install Source: https://docs.trygravity.com/gravity-index/install Connect the Gravity Index to your agent, your editor, or your terminal. This page tells you how to connect the Gravity Index to your agent, your editor, or your terminal. Select the interface that fits your work. | You use | Interface | | - | - | | An AI coding agent (Cursor, Claude Code, Claude Desktop, Windsurf, or a similar tool) | [MCP server](#mcp-server-recommended) | | A terminal or shell scripts | [CLI](#bash-cli) | | Your own agent or app | [REST API](#direct-api) | ## Before you start * Get a Gravity publisher API key. Every Index call needs it. Without a key, the Index returns `401`. To get a key, contact Gravity at `index@trygravity.ai`. * For the MCP server, install Node.js 18 or later. * For the CLI, install Python 3 and the `httpx` package. In the examples, replace `YOUR_API_KEY` with your publisher API key. *** ## MCP server (recommended) The Gravity Index MCP server gives your agent the Index as a set of tools. Your agent can call the tools directly. You do not write HTTP client code. The Gravity Index MCP server is not the Gravity dashboard MCP server for advertisers and publishers. It is a separate product. ### Add the server (npm) The npm package [`@gravity-ai/index-mcp`](https://www.npmjs.com/package/@gravity-ai/index-mcp) has no dependencies. `npx` downloads and runs it. You do not need Python, a clone, or a build step. Add the server to your client. Put your key in `GRAVITY_PUBLISHER_KEY`. ```bash Claude Code theme={null} claude mcp add gravity-index -e GRAVITY_PUBLISHER_KEY=YOUR_API_KEY -- npx -y @gravity-ai/index-mcp ``` ```json Claude Desktop theme={null} // macOS: ~/Library/Application Support/Claude/claude_desktop_config.json // Windows: %APPDATA%\Claude\claude_desktop_config.json { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"], "env": { "GRAVITY_PUBLISHER_KEY": "YOUR_API_KEY" } } } } ``` ```json Cursor theme={null} // Settings > MCP > Add new MCP server { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"], "env": { "GRAVITY_PUBLISHER_KEY": "YOUR_API_KEY" } } } } ``` ```json Windsurf theme={null} // Settings > Cascade > MCP servers { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"], "env": { "GRAVITY_PUBLISHER_KEY": "YOUR_API_KEY" } } } } ``` Source: [`sdk-js/packages/index-mcp`](https://github.com/Try-Gravity/gravity/tree/main/sdk-js/packages/index-mcp) ### Run the Python server from source (optional) If you have access to the Gravity repository, you can run the Python version of the server. It uses only the Python standard library. It has more tools than the npm server. See [MCP server](/gravity-index/mcp) for the differences. ```bash theme={null} git clone https://github.com/Try-Gravity/gravity.git cd gravity claude mcp add gravity-index \ -e GRAVITY_PUBLISHER_KEY=YOUR_API_KEY \ -- python3 index/mcp/gravity_index_mcp.py ``` Source: [`index/mcp/gravity_index_mcp.py`](https://github.com/Try-Gravity/gravity/tree/main/index/mcp) ### Restart your agent Restart Cursor, Claude, or Windsurf after you add the server. The npm server adds five tools: * `gravity_index_search`: find the best service for a request, for example "I need a database". * `gravity_index_browse`: list services. Filter by category or by keyword. * `gravity_index_list_categories`: list all categories, with the number of services in each. * `gravity_index_get_service`: get the full details of one service by its slug. * `gravity_index_report_integration`: tell the Index that the user finished an integration. ### Test the server Ask your agent: *"I need to add email sending to my Next.js app. Search the Gravity Index for options."* The agent calls `gravity_index_search`. You see a recommendation, the reasons for it, and a setup link. *** ## Bash CLI The CLI lets you search the Index from a terminal, a shell script, or a CI job. The CLI is in the Gravity repository and is not on a package registry. Use it only if you have repository access. For public agent integrations, use the npm MCP server. ### Install the CLI 1. Go to your local copy of the Gravity repository. ```bash theme={null} cd gravity ``` 2. Copy the script to a folder on your `PATH`. ```bash theme={null} mkdir -p ~/.local/bin cp index/cli/gravity-index ~/.local/bin/gravity-index chmod +x ~/.local/bin/gravity-index ``` 3. Make sure that `~/.local/bin` is on your `PATH`. Add this line to `~/.zshrc` or `~/.bashrc`: ```bash theme={null} export PATH="$HOME/.local/bin:$PATH" ``` 4. Install `httpx`. The CLI uses it for HTTP calls. ```bash theme={null} pip install --user httpx ``` 5. Set your publisher API key. ```bash theme={null} export GRAVITY_PUBLISHER_KEY=YOUR_API_KEY ``` ### Use the CLI ```bash theme={null} # Search gravity-index search "I need a serverless database" # Search with identity fields for attribution gravity-index search "I need a serverless database" \ --external-session-id session_123 \ --external-user-id-hash sha256_user_id \ --email-hash sha256_email \ --metadata-json '{"surface":"chat"}' # Browse the catalog gravity-index browse gravity-index browse --category Database gravity-index browse "vector" # Get the details of one service gravity-index info supabase ``` The CLI prints color output with the recommendation, the reasons, and a tracked setup link. See [CLI](/gravity-index/cli) for all commands and flags. *** ## Direct API To call the Index from your own agent or from any language, use the REST API at `https://index.trygravity.com`. ```bash theme={null} # Health check (no key needed) curl https://index.trygravity.com/health # Search curl -X POST https://index.trygravity.com/search \ -H 'Content-Type: application/json' \ -H 'X-API-Key: YOUR_API_KEY' \ -d '{ "query": "I need a transactional email API for AI agents", "external_session_id": "session_123", "external_user_id_hash": "sha256_user_id", "hashed_email": "sha256_email", "metadata": {"surface": "chat"} }' # Browse the catalog curl -H 'X-API-Key: YOUR_API_KEY' 'https://index.trygravity.com/services' curl -H 'X-API-Key: YOUR_API_KEY' 'https://index.trygravity.com/services/supabase' ``` The health check returns `{"status": "ok", "service": "gravity-index"}`. For the full reference, see [Search](/gravity-index/search) and [Product search](/gravity-index/products). *** ## Publisher attribution Gravity uses your publisher API key to attribute searches, clicks, and completed integrations to your account. For better attribution, send stable identity fields on every `/search` call: | Field | Type | Notes | | - | - | - | | `external_session_id` | string | Your stable chat or session ID, up to 256 characters. It also turns on [conversation context](/gravity-index/conversation-context). | | `external_user_id_hash` | string | A hash of your user ID. Send this in place of a raw ID when you can. | | `external_user_id` | string | A raw user ID (optional). Gravity stores only a SHA-256 hash of it. | | `hashed_email` | string | The SHA-256 hash of the normalized user email, if you have it. The Index also accepts the old name `email_hash`. | | `phone_hash` | string | The SHA-256 hash of the user phone number (digits only), if you have it. | | `metadata` | object | Context that is not sensitive, for example the surface, plan, workspace, or placement. | The MCP server and the CLI send the key from `GRAVITY_PUBLISHER_KEY` for you. The MCP server and the CLI send the email hash as `email_hash`. *** ## Troubleshoot > **npx fails with ENOENT or cannot find the package** > > 1. Make sure that Node.js 18 or later is on your machine: `node -v`. > 2. Install the package globally: `npm install -g @gravity-ai/index-mcp`. > 3. Find the binary: `which gravity-index-mcp`. > 4. In your MCP config, replace the `npx` command with the absolute path from step 3. > **The MCP tools do not show after a restart** > > Look at the MCP server status or logs: > > * **Claude Desktop**: `~/Library/Logs/Claude/mcp*.log` (macOS). > * **Cursor**: Settings > MCP. Look at the status next to `gravity-index`. > * **Claude Code**: run `claude mcp list`. > > A frequent cause is that `npx` cannot get to the npm registry. To test the server, run this command in a terminal: > > ```bash theme={null} > echo '{"jsonrpc":"2.0","id":1,"method":"initialize"}' | npx -y @gravity-ai/index-mcp > ``` > > The server prints one JSON line that contains `"serverInfo":{"name":"gravity-index"`. > **Every tool call fails with 401 platform\_api\_key required** > > The server did not send a publisher API key. Set `GRAVITY_PUBLISHER_KEY` in the `env` block of your MCP config, or export it in your shell for the CLI. Then restart the agent. > **The CLI prints Install httpx** > > The CLI needs `httpx`. Install it with `pip install --user httpx`. > **Search is slow or times out** > > The `/search` endpoint calls an LLM to select a service, so a response can take some seconds. The CLI waits up to 30 seconds. If you get timeouts all the time, look at `https://index.trygravity.com/health`. If the problem continues, contact `index@trygravity.ai`. # Gravity Index Source: https://docs.trygravity.com/gravity-index/introduction A recommendation API for AI agents. It finds developer services and consumer products for your users. The Gravity Index is a recommendation API for AI agents. Your agent calls it when a user needs something the agent cannot make itself, for example a database, an email API, or a pair of running shoes. The Index has two verticals (catalogs). One publisher API key works for both. | Vertical | Endpoint | Response | | - | - | - | | **Developer services** | [`POST /search`](/gravity-index/search) | One recommendation, the reasons for it, and a tracked link | | **Consumer products** | [`POST /shop/search`](/gravity-index/products) | A ranked list of in-stock products, each with a price, an image, and a tracked link | For developer services, an LLM reads the request and the catalog. It selects one service and tells you why it fits. For consumer products, the Index does a structured search. You can filter by brand, merchant, price, and sale status, and the results come back in order of relevance. ## Who can use the Index Any Gravity publisher can use the Index. A publisher is a platform that shows Gravity content to its users. Your publisher API key works on all Index endpoints. You do not need a separate sign-up. * **Approved accounts** get full access. Searches, clicks, and conversions count toward your earnings. * **Pending accounts** can call the API and build an integration. Gravity does not attribute or pay anything until Gravity approves the account. * **Blocked accounts** get `403` on every Index call. This is the same rule that applies to production ads. ## How it works **1. The agent sends a search** Your agent calls `POST /search` with a request in natural language, for example "I need a serverless database with a free tier." **2. The Index selects a service** The Index compares the request with the catalog. It selects the best match and writes the reasons for the choice. **3. The agent shows the recommendation** The response has the recommended service, the reasons, and a tracked link. The agent tells the user about it in its own words. **4. The user opens the link** The user opens the tracked link and signs up with the service. Gravity records the click for attribution. Gravity charges the service only after a confirmed conversion. ## Set up > **[Install](/gravity-index/install)** > > Select an interface (MCP server, CLI, or REST API) and connect it to the Index. ## Interfaces > **[MCP server](/gravity-index/install#mcp-server-recommended)** > > `npx -y @gravity-ai/index-mcp`: a tool server for Cursor, Claude Code, Claude Desktop, and Windsurf. > **[CLI](/gravity-index/install#bash-cli)** > > `gravity-index search "database"`: for the terminal and for scripts. > **[REST API](/gravity-index/install#direct-api)** > > `POST https://index.trygravity.com/search`: for your own integration. The Gravity Index MCP server is a different product from the Gravity dashboard MCP server for advertisers and publishers. On the Index pages, "MCP server" always means the Gravity Index MCP server. ## Base URL ``` https://index.trygravity.com ``` ## Key concepts * **Publisher API key**: the key that identifies your platform. Send it as the `X-API-Key` header or as the `platform_api_key` body field. * **`search_id`**: a unique ID for each search. Send it back with a follow-up question, and the Index uses the earlier search as context. * **Click link (`click_url`)**: a tracked short URL. The user opens it to go to the recommended service or product. Each link has a `grclid` (Gravity click ID) for attribution. A click alone does not cause a charge. Gravity charges only after a confirmed conversion. * **Conversation context**: send your stable `external_session_id`, and the Index uses a summary of that conversation as private context. See [Conversation context](/gravity-index/conversation-context). * **No auction**: services do not bid on each search. Relevance decides the recommendation. An optional publisher setting can give a small boost to services with an active CPA (cost per acquisition) campaign. The response never shows prices or payouts. # Agent Purchases Source: https://docs.trygravity.com/gravity-index/link-purchases Report to Gravity the purchases that your agent completes with Stripe Link. This page tells you how to report to Gravity the purchases that your agent completes with [Stripe Link](https://github.com/stripe/link-cli). You send one server-to-server call for each purchase, like the advertiser [Conversions API](/advertisers/server-conversions). > [!NOTE] > > Now, `/purchases/report` only records the purchase. A report does not create a charge, an attribution, or a payout by itself. Attribution and payouts come from the advertiser conversion feed. Gravity stores the `grclid` and the hashed email so that it can connect each purchase to your Index searches. ## How it works 1. `/search` returns a recommendation with a `grclid` (Gravity click ID). If the merchant accepts machine payments, the recommendation also has a `checkout` pointer that the agent can pay directly. 2. Your agent pays the merchant with Link, or gives the user `click_url`. Keep the `grclid` with the spend request. 3. When the spend request is complete, your backend sends it to `/purchases/report` with the `grclid` and the buyer's hashed email. Send every purchase that your agent completes. Some purchases have a `grclid` from an Index result. Send the purchases without a `grclid` too. ## Before you start * Get your publisher API key. This is the key that you use for `/search`. * Install the Link CLI (`@stripe/link-cli`) for your agent. ## 1. Pay with Link The Link CLI is a tool that the agent calls. The user signs in one time with `link-cli auth login`. Only US and Canadian Link accounts are supported now. **MCP server** Add the Link MCP server to your MCP client config (`.mcp.json` or an equivalent file). Each CLI command becomes a tool. ```json theme={null} { "mcpServers": { "link": { "command": "npx", "args": ["@stripe/link-cli", "--mcp"] } } } ``` Or run `link-cli serve` and point your host at `http://127.0.0.1:54321/mcp`. **CLI as a tool** Run the CLI from your agent with `--format json`. For a result with `checkout.protocol == "mpp"`, one command does the full flow: ```bash theme={null} npx @stripe/link-cli mpp pay "$CHECKOUT_ENDPOINT" \ --context "Buying for . Recommended by Gravity Index, click . The user asked for this and approves the price shown in the merchant's 402 challenge." \ --format json ``` Without `--spend-request-id`, the command does these steps: it gets the `402` response, reads the challenge, creates the spend request, waits for the user's approval, pays, and prints the spend request and the merchant response. On this path, `--context` is required and must have at least 100 characters. The user reads it on the approval screen. In JSON mode, the output has `_next.poll_argv` and `_next.pay_argv` for approval polls. Give those argv arrays directly to the CLI. Do not send them through a shell. For a merchant checkout that takes a card, create the spend request yourself. Put the `grclid` in its metadata, so that it comes back when you retrieve the spend request: ```bash theme={null} link-cli spend-request create --credential-type card \ --merchant-name "$MERCHANT" --merchant-url "$MERCHANT_URL" \ --amount "$AMOUNT_CENTS" --currency usd \ --metadata "grclid:$GRCLID" --context "..." --request-approval --format json ``` For Stripe-hosted checkouts that accept a Link Pay Token, follow the [Link Pay Token](https://github.com/stripe/link-cli#link-pay-token) section of the Link CLI README. Add the same `--metadata "grclid:$GRCLID"`. In a `/search` result, `recommendation.checkout` has one of two values: * `{protocol: "mpp", endpoint, network_id, attribution}`: pay `endpoint` directly. It already contains `?grclid=` and the click ID. * `null`: there is no machine checkout. Use `click_url`. `checkout.network_id` is the merchant's Stripe `profile_` ID. It can be `null`. `mpp pay` reads the ID from the live `402` response, so you need it only when you create the spend request yourself. `link-cli mpp decode` gets the ID from a raw `WWW-Authenticate` header. Link limits a spend request to \$500. ## 2. Report the purchase When the spend request gets to a final status, send it from your backend. Send all the data that you have: the merchant, the line items, the amounts, the IDs from Link, the user's request that caused the purchase, and the raw Link response. The endpoint is idempotent on `spend_request_id` for your publisher, so a retry is safe. ```bash theme={null} curl -X POST https://index.trygravity.com/purchases/report \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "spend_request_id": "lsrq_EXAMPLE123", "status": "succeeded", "grclid": "2f1e0e0d-b6be-4621-b67a-be4ebfb921aa", "hashed_email": "b4c9a289323b21a01c3e940f150eb9b8c542587f1abfd8f0e1cc1ffc5e475514", "merchant_name": "Acme", "merchant_domain": "acme.com", "merchant_url": "https://acme.com/pro", "merchant_id": "acct_EXAMPLE", "line_items": [ {"name": "Acme Pro (annual)", "sku": "acme-pro-1y", "category": "software", "quantity": 1, "unit_price": 4900, "url": "https://acme.com/pro"} ], "currency": "usd", "subtotal": 4900, "tax": 0, "amount": 4900, "credential_type": "shared_payment_token", "network_id": "profile_EXAMPLE", "transaction_id": "pi_EXAMPLE", "link_transaction_id": "ltxn_EXAMPLE", "link_session_id": "lsess_EXAMPLE", "purchased_at": "2026-10-22T18:04:11Z", "user_query": "project tracker for a 5 person team under $50", "agent_id": "shopper-v2", "session_id": "sess_EXAMPLE", "metadata": {"order_id": "A-1001", "receipt_url": "https://acme.com/r/A-1001"}, "link_response": {"id": "lsrq_EXAMPLE123", "status": "succeeded"} }' ``` ### Authentication Send your publisher API key in the `X-API-Key` header. This endpoint does not accept the key in the body. ### Request fields Only `spend_request_id` and `status` are required. **spend\_request\_id** `string` *required* The Link `lsrq_` ID, up to 64 characters. Gravity uses it to remove duplicates. **status** `string` *required* The final status of the spend request. Use `succeeded` for a completed purchase. The endpoint also accepts `failed`, `denied`, `canceled`, `expired`, and `approved` (approved, but you did not see the charge) for reconciliation. **Attribution** **grclid** `string` Send it when the purchase came from an Index result: use `checkout.attribution.click_id_value` or the result's `grclid`. It must be a `grclid` from a search that you made with your key. Leave it out when there is none. **hashed\_email** `string` The SHA-256 hash of the buyer's email as 64 hex characters, after you trim it and change it to lowercase. This is the same format as `hashed_email` on `/search`. Include it in every report. **Merchant** **merchant\_name** `string` The merchant name from the spend request. **merchant\_domain** `string` For example `acme.com`. **merchant\_url** `string` The product or checkout URL. **merchant\_id** `string` The Stripe account ID or merchant ID, if you have it. **network\_id** `string` The merchant `profile_` ID from the spend request (MPP only). **Items and amounts** (in minor units, for example cents) **line\_items** `object[]` One item for each product: `name`, `sku`, `category`, `quantity`, `unit_price`, `url`. Gravity keeps other keys too. Up to 500 items. **currency** `string` An ISO 4217 code, three letters. **subtotal** `integer` **tax** `integer` **amount** `integer` The total that the merchant charged. **IDs and time** **credential\_type** `string` `shared_payment_token`, `card`, or `link_pay_token`. **transaction\_id** `string` The merchant or Stripe payment ID (`pi_` ID, or an order ID). **link\_transaction\_id** `string` The `ltxn_` ID. **link\_session\_id** `string` **purchased\_at** `string` The ISO 8601 time of the charge. **Context** **user\_query** `string` What the user asked for that caused this purchase. **agent\_id** `string` The agent of yours that made the purchase. **session\_id** `string` Your conversation or session ID. **Other data** **metadata** `object` Any JSON. Gravity stores it as you send it, with no schema. **link\_response** `object` The spend request object exactly as `link-cli` returned it (`--format json`). Unknown top-level fields return `422`. Put other data in `metadata`. Each of `metadata`, `line_items`, and `link_response` can be at most 64 KB. > [!WARNING] > > Do not send Link access tokens, Shared Payment Tokens, card numbers, or the raw credential object. A report to Gravity is separate from the Link command `link-cli report`, which sends data to Stripe. ### Responses | Response | Description | | - | - | | `200 {"status": "recorded", "grclid": "..."}` | Gravity stored the report. `grclid` is `null` if you did not send one. | | `200 {"status": "duplicate", "grclid": "..."}` | You already reported this `spend_request_id`. Nothing changed. | | `400 unknown status` | `status` is not in the list above. | | `400 invalid grclid` | `grclid` is not a UUID. | | `401 platform_api_key required` | The request has no `X-API-Key` header. | | `403` | The key is not valid, or the publisher account is blocked. | | `404 unknown grclid` | You sent a `grclid`, but it is not from a search that you made with this key. | | `413` | `metadata`, `line_items`, or `link_response` is more than 64 KB. | | `422` | An unknown field, a field that is too long, or a `hashed_email` that is not 64 hex characters. | ## Contact If your Link setup does not fit one of the shapes on this page, write to `index@trygravity.ai`. # MCP Server Source: https://docs.trygravity.com/gravity-index/mcp Give your AI agent the Gravity Index as a set of MCP tools. The Gravity Index MCP server gives AI coding agents the Index as a set of tools. The agent calls the tools directly, so you do not write HTTP client code. This page is about the Gravity Index MCP server. It is a different product from the Gravity dashboard MCP server for advertisers and publishers. ## Two versions of the server | Version | How to run it | Tools | | - | - | - | | npm package [`@gravity-ai/index-mcp`](https://www.npmjs.com/package/@gravity-ai/index-mcp) (recommended) | `npx -y @gravity-ai/index-mcp`. Needs Node.js 18 or later. No other dependencies. | Five tools. Developer services only. | | Python server in the Gravity repository | `python3 index/mcp/gravity_index_mcp.py`. Uses only the Python standard library. Needs repository access. | Six tools. Also searches consumer products and creates accounts for users. | Both versions call the same API at `https://index.trygravity.com`. Both return plain-text Markdown that the agent can show to the user. ## Install the npm server Every tool call needs your publisher API key. Put it in `GRAVITY_PUBLISHER_KEY`. Without a key, every call fails with `401`. ```json Claude Desktop theme={null} // macOS: ~/Library/Application Support/Claude/claude_desktop_config.json // Windows: %APPDATA%\Claude\claude_desktop_config.json { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"], "env": { "GRAVITY_PUBLISHER_KEY": "YOUR_API_KEY" } } } } ``` ```json Cursor theme={null} // Settings > MCP > Add server { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"], "env": { "GRAVITY_PUBLISHER_KEY": "YOUR_API_KEY" } } } } ``` ```bash Claude Code theme={null} claude mcp add gravity-index -e GRAVITY_PUBLISHER_KEY=YOUR_API_KEY -- npx -y @gravity-ai/index-mcp ``` For more clients and for help with problems, see [Install](/gravity-index/install). ## Install the Python server ```bash theme={null} claude mcp add gravity-index \ -e GRAVITY_PUBLISHER_KEY=YOUR_API_KEY \ -- python3 /path/to/gravity/index/mcp/gravity_index_mcp.py ``` ## Environment variables Both versions read the same variables. | Variable | Required | Description | | - | - | - | | `GRAVITY_PUBLISHER_KEY` | Yes | Your publisher API key. The server sends it as `platform_api_key`. | | `GRAVITY_INDEX_URL` | No | The API base URL. The default is `https://index.trygravity.com`. | | `GRAVITY_INDEX_MONETIZATION_MODE` | No | Set to `boost_cpa` for relevance-first results that give a small boost to services with an active CPA campaign. The tool output never shows prices or payouts. | ## Tools ### `gravity_index_search` Finds the best service for a development task. The result has the recommendation, the reasons for it, setup steps, the environment variables, and a tracked setup link. The output ends with the `search_id` and the slug. Keep them for `gravity_index_report_integration`. Parameters of the npm server: | Parameter | Type | Required | Description | | - | - | - | - | | `query` | string | Yes | What you need, for example `"serverless database with free tier"`. | | `search_id` | string | No | The `search_id` of an earlier search. Use it for a follow-up question. | | `context` | object | No | Project context, for example the framework, language, runtime, package manager, or constraints. | | `external_session_id` | string | No | Your session ID, for attribution and [conversation context](/gravity-index/conversation-context). | | `external_user_id_hash` | string | No | A hash of your user ID. | | `email_hash` | string | No | The SHA-256 hash of the normalized user email. | | `metadata` | object | No | Publisher metadata that is not sensitive. The Index stores it with the search. | The Python server accepts the same parameters, and also these: | Parameter | Type | Description | | - | - | - | | `vertical` | string | `developer`, `consumer`, or `auto`. If you do not send it, the API uses `auto`. With `auto`, the Index sends requests for retail products to the product catalog only if Gravity turned on consumer search for your account. Use `consumer` for [product search](/gravity-index/products). | | `filters` | object | Hard filters for product search: `brands[]`, `merchants[]`, `price_min`, `price_max`, `currency`, `sizes[]`, `colors[]`, `gender`, `categories[]`, `on_sale_only`. If you send filters with `auto`, the Index uses the product catalog. | | `phone_hash` | string | The SHA-256 hash of the user phone number (digits only). | In the Python server, `query` is optional when `context.messages` contains the conversation. **Example** ``` User: "I need a database for my project." Agent: gravity_index_search({ query: "database" }) Result: "Neon (Database). Serverless Postgres with a free tier. Step 1: npm install @neondatabase/serverless Step 2: Create lib/db.ts Step 3 (REQUIRED): Open the setup link, get DATABASE_URL, and paste it back." ``` ### `gravity_index_browse` Lists services in the catalog. Filter by category or by keyword. | Parameter | Type | Required | Description | | - | - | - | - | | `category` | string | No | A category name, for example `Database`. | | `q` | string | No | A keyword to find in the name, description, or tags. | ### `gravity_index_get_service` Gets the full details of one service: description, setup steps, environment variables, website, and docs URL. | Parameter | Type | Required | Description | | - | - | - | - | | `slug` | string | Yes | The service slug, for example `"supabase"`. | ### `gravity_index_list_categories` Lists all categories, with the number of services in each. This tool has no parameters. ### `gravity_index_report_integration` Tells the Index that the user finished an integration with the recommended service. Use it when the user did not open the setup link. Send the `search_id` from the earlier search and the slug of the service that the user integrated. The slug must be the slug that the search recommended. | Parameter | Type | Required | Description | | - | - | - | - | | `search_id` | string | Yes | The `search_id` from an earlier `gravity_index_search` call. | | `integrated_slug` | string | Yes | The slug of the service, for example `"stripe"`. | If Gravity already recorded the integration for this search, the tool says so and changes nothing. ### `gravity_index_provision` (Python server only) Creates an account at a recommended service for the user. > [!WARNING] > > Call this tool only after the user agrees to account creation. Set `user_consent` to `true` to confirm that the user agreed. | Parameter | Type | Required | Description | | - | - | - | - | | `service_slug` | string | Yes | The catalog slug from a search result, for example `"neon"`. | | `user_email` | string | Yes | The user email. The service creates the account for this email. | | `user_consent` | boolean | Yes | Must be `true`. | | `search_id` | string | No | The `search_id` of the search that recommended the service. | | `external_user_id` | string | No | Your stable ID for the user. Do not send the email here. | | `config` | object | No | Settings for the service, for example `{"region": "aws-us-east-1"}`. | The result is either credentials and an ownership link, or a signup link that the user must open. The server writes credential values to a local `.env.gravity` file. It does not put them in the conversation. # Product search Source: https://docs.trygravity.com/gravity-index/products POST https://index.trygravity.com/shop/search Find consumer products that a user can buy (shoes, cookware, electronics), not developer tools. `POST /shop/search` finds consumer products that a user can buy. The Index has two verticals (catalogs) behind one publisher API key: | The user wants | Call | The response | | - | - | - | | A developer service ("a Postgres with a free tier", "email API for Node") | [`POST /search`](/gravity-index/search) | **One** recommendation, the reasons for it, and a tracked link | | A physical product ("running shoes for wide feet under \$150") | `POST /shop/search` (this page) | A **ranked list** of in-stock products, each with a price, an image, and a tracked link | Your agent selects the endpoint from the user's intent. Use this rule: if the answer is software or a service that the user signs up for, call `/search`. If the answer is an item that a store ships to the user, call `/shop/search`. Both endpoints accept the same identity fields. ## Authentication Use the same key as for all other Index endpoints. Send your publisher API key in the `X-API-Key` header or in the `platform_api_key` body field. You do not need a separate entitlement for the Index or for products. A request without a key gets `401`. A blocked publisher gets `403`. ## Request **query** `string` A description of the product in natural language, up to 1,000 characters. The Index does a full-text search on the product name, brand, merchant, and category. Concrete nouns give better results: "wide toe box running shoes" is better than "something comfy for my feet". The field is required unless you send `context.messages`. **context** `object` Optional hints from the conversation. `context.style` (alias `context.vibe`) is free text that gives a soft boost in the order, for example `"neutral cushioned daily trainer"` or `"minimalist Scandinavian"`. `context.messages` (`[{role, content}]`) lets the Index find the product need in the conversation when you send no `query`. The Index uses the last 12 turns. The `query` field of the response shows what the Index found. The Index stores other keys with the search and does not use them now. **max\_results** `integer` (default: 5) 1 to 25. Ask only for the number that you will show, because each result makes a tracked click link. **rerank** `boolean` (default: true) When `true`, an LLM reranks the results. It keeps only the products that fit the request, so the list can be empty. When `false`, you get the keyword order, and no LLM runs. ### Structured filters The Index applies filters **before** it ranks the results. Filters are hard limits, not preferences. Send them only when the user stated them. **price\_min** `number` The lowest current (sale) price, in `currency`. Inclusive. Must be 0 or more. **price\_max** `number` The highest current (sale) price, in `currency`. Inclusive. Must be 0 or more. **currency** `string` (default: USD) An ISO 4217 code. Price filters and returned prices use this currency. Most of the catalog is in USD. **brands** `string[]` Up to 20 brand names. The match ignores case (`"Nike"` and `"nike"` are the same). Use this filter when the user names a brand. Do not use it for "any brand". **merchants** `string[]` Up to 20 merchant names (`"Nike"`, `"Walmart"`, `"Lululemon"`). The match ignores case. Use this filter when the user wants to buy from a specific store. **on\_sale\_only** `boolean` (default: false) Return only products that have a price below their regular price now. **colors** `string[]` Up to 10 color words (`"black"`, `"navy"`). Listings are for a product, not for a variant, so this filter matches the color in the product title. **sizes** `string[]` Up to 10 sizes, as the merchant writes them (`"10"`, `"M"`, `"32x34"`). The Index removes listings that name a different size. It keeps listings that name no size, because the user can select the size at checkout. **gender** `string` `men`, `women`, or `kids`. **categories** `string[]` Up to 10 of these values: `apparel`, `footwear`, `furniture_decor`, `home_kitchen`, `beauty_personal_care`, `electronics_computing`, `jewelry_watches`, `sports_outdoors`, `tools_hardware`, `toys_kids`, `baby`, `pets`, `auto`, `books_media`, `other`. Any other value returns `422`. > [!NOTE] > > The merchant catalog sets the region (US stores now). There is no region filter. ### Identity and attribution (optional) These fields are the same as on `/search`. Send them for attribution and conversion reports for each user. **external\_session\_id** `string` Your ID for this conversation, up to 200 characters on this endpoint. **external\_user\_id** `string` Your stable ID for this user. Gravity stores only a SHA-256 hash of it. **external\_user\_id\_hash** `string` A hash of your user ID. Send this in place of `external_user_id` when you can. **hashed\_email** `string` The SHA-256 hash of the user email, after you trim it and change it to lowercase. The Index also accepts the old name `email_hash`. **phone\_hash** `string` The SHA-256 hash of the user phone number (digits only). **metadata** `object` Free-form keys and values that Gravity stores with the search (surface, locale, A/B arm). The JSON must be 20,000 characters or fewer. Do not put secrets or raw personal data here. ## Response **vertical** `string` Always `"consumer"` on this endpoint. If one handler processes both endpoints, use this field to select the display. **api\_version** `string` The response contract version, now `"2026-09-09"`. **search\_id** `string` A unique ID for this search. Log it. It is the join key for click and conversion reports. **query** `string` The product request that the Index searched: your `query`, or what the Index found in `context.messages`. **recommendation** `object | null` `products[0]`, or `null` when no product fits. **reasoning** `string` One sentence on why the Index selected the top product, or why it selected none. **products** `object[]` The products, best first. The list is empty when no in-stock product matches the query **and** all filters. Make a filter wider or change the words before you tell the user that there is nothing. **product** **product\_id** `string` A stable catalog ID. **name** `string` The merchant's product title, as the merchant wrote it. **brand** `string | null` **merchant** `string` The store that sells and ships the product. **category** `string | null` The merchant's category path, for example `Clothing > Shoes > Mens Shoes > Mens Running Shoes`. `null` for live Shopify listings. **description** `string` Shortened to 400 words or fewer. **currency** `string` **price** `number` The current price that the user pays. **regular\_price** `number | null` The list price. It is the same as `price` when the product is not on sale. `null` when the merchant does not publish one. **on\_sale** `boolean` **image\_url** `string` The product image. Always present: the Index removes products that have no image. **click\_url** `string` The tracked link to the merchant's product page. **Show only this URL to the user.** It sends the user to the merchant and records the click against `search_id`. In iMessage, Slack, and WhatsApp it shows as a product card (image, price, merchant). **grclid** `string` The Gravity click ID in `click_url`. Use it if you report conversions yourself. **checkout** `object | null` A destination for machine payment, when the merchant accepts agent checkout (now: Shopify storefronts, through [UCP](https://ucp.dev)). `null` for listings that convert only through `click_url`. See [Buy for the user](#buy-for-the-user). **checkout** **protocol** `string` `"ucp"`. **endpoint** `string` The merchant's own UCP MCP endpoint (`https://STORE_DOMAIN/api/ucp/mcp`). Your agent calls it directly. Gravity is not in the payment path. **continue\_url** `string` The merchant's browser checkout for this variant. Give it to the user when the agent does not complete the purchase. **item\_id** `string` The ID for `line_items[].item.id`, for example `gid://shopify/ProductVariant/32262292013136`. **agent\_profile** `string` A hosted UCP agent profile. Send it as `meta.ucp-agent.profile` if you do not host your own. **attribution** `object` Send it without changes as the `attribution` argument of `create_checkout`. It puts the `grclid` on the merchant's order, in the same slot that Google uses for `gclid`. **count** `integer` `products.length`. **web\_results** `object[]` Has items only when `products` is empty: up to 5 plain web listings for the same request, so the agent has something to show. Each item has `name`, `brand`, `price`, `currency`, `description`, `image_url`, `url`, and `site`. `price` and `currency` are `null` unless the page states both. Do not make up a price. These items have **no** tracked link and **no** `checkout`. Link to `url` directly, tell the user that they come from the web and not from the catalog, and do not offer to buy them. **web result** **name** `string` The product or page title. **brand** `string | null` **price** `number | null` The listed price in `currency`, or `null`. **currency** `string | null` An ISO 4217 code, only when the page states it. **description** `string | null` One sentence. **image\_url** `string | null` A verified raster image, or `null`. **url** `string` The page. Not tracked. **site** `string` The hostname without `www.`, for example `chewy.com`. **ranking** `string` How the Index put the list in order. `"llm"`: the reranker kept only products that fit, best relevance tier first. In one tier, the listing that pays your platform more comes first. `"retrieval"`: keyword order (`rerank: false`, or the reranker was not available). For information only. **facets** `object` Values that the agent can filter on next, from the retrieved set: `brands`, `merchants`, `categories` (the 10 most frequent of each), and `price_range` (`{min, max}` or `null`). `categories` uses the values of the `categories` filter, for example `footwear`. The products do not have a `reasoning` field each. The Index ranks the products as a list. ```json Query + price cap theme={null} { "query": "running shoes for wide feet", "price_max": 150, "max_results": 2, "external_user_id": "user_8f3a" } ``` ```json Brand + size + style hint theme={null} { "query": "running shoes", "brands": ["Nike", "Brooks", "Hoka"], "sizes": ["10"], "gender": "men", "price_min": 80, "price_max": 180, "context": { "style": "neutral cushioned daily trainer" }, "max_results": 3 } ``` ```json From conversation only theme={null} { "context": { "messages": [ { "role": "user", "content": "my espresso machine died, I want something under $500 that does milk too" } ] }, "max_results": 3 } ``` ```json 200 with products theme={null} { "api_version": "2026-09-09", "vertical": "consumer", "search_id": "885eb1e4-ad46-417a-ae3f-7259291132e6", "query": "running shoes for wide feet", "recommendation": { "product_id": "1024849053674592846", "name": "Fdrone Women s Wide Toe Box Mesh Shoes Road Running Shoes", "brand": "Fdrone", "merchant": "Walmart", "category": "Clothing > Shoes > Womens Shoes > Womens Shoes", "description": "Elevate your active lifestyle with our premium women s sports shoes ...", "currency": "USD", "price": 17.0, "regular_price": 18.89, "on_sale": true, "image_url": "https://i5.walmartimages.com/asr/7593a41e-....jpeg", "click_url": "https://index.trygravity.com/go/e92f37c0-e2a3-4ba0-8526-b93e783becd1", "grclid": "e92f37c0-e2a3-4ba0-8526-b93e783becd1", "checkout": null }, "reasoning": "Fdrone Women s Wide Toe Box Mesh Shoes from Walmart is the closest match; the Allbirds pair also has a wide fit and is under the cap.", "products": [ { "product_id": "1024849053674592846", "name": "Fdrone Women s Wide Toe Box Mesh Shoes Road Running Shoes", "brand": "Fdrone", "merchant": "Walmart", "category": "Clothing > Shoes > Womens Shoes > Womens Shoes", "description": "Elevate your active lifestyle with our premium women s sports shoes ...", "currency": "USD", "price": 17.0, "regular_price": 18.89, "on_sale": true, "image_url": "https://i5.walmartimages.com/asr/7593a41e-....jpeg", "click_url": "https://index.trygravity.com/go/e92f37c0-e2a3-4ba0-8526-b93e783becd1", "grclid": "e92f37c0-e2a3-4ba0-8526-b93e783becd1", "checkout": null }, { "product_id": "shopify:32262292013136", "name": "Women's Tree Dasher 2 - Natural White", "brand": "Allbirds", "merchant": "Allbirds", "category": null, "description": "A lightweight everyday running shoe with a roomy toe box ...", "currency": "USD", "price": 135.0, "regular_price": null, "on_sale": false, "image_url": "https://cdn.shopify.com/s/files/1/1104/4168/files/TD2W-NW-1.png", "click_url": "https://index.trygravity.com/go/2a7f0c9e-51d3-4c0a-9b8e-6f2d1e4c8a11", "grclid": "2a7f0c9e-51d3-4c0a-9b8e-6f2d1e4c8a11", "checkout": { "protocol": "ucp", "endpoint": "https://www.allbirds.com/api/ucp/mcp", "continue_url": "https://www.allbirds.com/cart/32262292013136:1", "item_id": "gid://shopify/ProductVariant/32262292013136", "agent_profile": "https://index.trygravity.com/ucp/agent-profile.json", "attribution": { "referring_domain": "index.trygravity.com", "click_id_tag": "grclid", "click_id_value": "2a7f0c9e-51d3-4c0a-9b8e-6f2d1e4c8a11" } } } ], "count": 2, "web_results": [], "ranking": "llm", "facets": { "brands": ["Fdrone", "Allbirds", "Brooks", "New Balance"], "merchants": ["Walmart", "Allbirds", "Zappos"], "categories": ["footwear"], "price_range": { "min": 17.0, "max": 139.95 } } } ``` ```json 200 with no catalog match (web fallback) theme={null} { "api_version": "2026-09-09", "vertical": "consumer", "search_id": "bc9fe59c-0b1e-4d3a-9c7f-2e8a4d6b1f05", "query": "Zorblax titanium dog leash", "recommendation": null, "reasoning": "No in-stock product in the catalog matches this request and its filters; web_results are plain web listings with no tracked link or checkout.", "products": [], "count": 0, "web_results": [ { "name": "Titanium Dog Leash - Ultralight", "brand": "Zorblax", "price": 18.5, "currency": "USD", "description": "A 5 ft titanium-clip leash for dogs up to 60 lb.", "image_url": "https://zorblax.example/cdn/leash-ti.jpg", "url": "https://zorblax.example/products/titanium-dog-leash", "site": "zorblax.example" }, { "name": "Zorblax leash review", "brand": null, "price": null, "currency": null, "description": "Hands-on review of the Zorblax titanium leash.", "image_url": null, "url": "https://dogblog.example/zorblax-leash-review", "site": "dogblog.example" } ], "ranking": "retrieval", "facets": { "brands": [], "merchants": [], "categories": [], "price_range": null } } ``` ```json 422 validation theme={null} { "detail": [ { "type": "value_error", "loc": ["body"], "msg": "Value error, query is required unless context.messages carries the conversation" } ] } ``` ## Examples ### Query in natural language with a price limit ```bash cURL theme={null} curl -X POST https://index.trygravity.com/shop/search \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "running shoes for wide feet", "price_max": 150, "max_results": 3, "external_user_id": "user_8f3a" }' ``` ```typescript TypeScript theme={null} const res = await fetch("https://index.trygravity.com/shop/search", { method: "POST", headers: { "X-API-Key": process.env.GRAVITY_PUBLISHER_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ query: "running shoes for wide feet", price_max: 150, max_results: 3, external_user_id: "user_8f3a", }), }); const { products, search_id } = await res.json(); ``` ```python Python theme={null} import httpx, os r = httpx.post( "https://index.trygravity.com/shop/search", headers={"X-API-Key": os.environ["GRAVITY_PUBLISHER_KEY"]}, json={ "query": "running shoes for wide feet", "price_max": 150, "max_results": 3, "external_user_id": "user_8f3a", }, ) products = r.json()["products"] ``` The response panel at the top of this page shows a full response with two products, a response with no catalog match (with `web_results`), and a `422` error. `recommendation` repeats `products[0]`. `checkout` is `null` for affiliate catalog listings and has a value for Shopify storefronts. ### Brand, price band, and a style hint The user said: "I want Nikes or Brooks, something cushioned for daily miles, under \$180." ```bash theme={null} curl -X POST https://index.trygravity.com/shop/search \ -H "X-API-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "running shoes", "brands": ["Nike", "Brooks", "Hoka"], "price_min": 80, "price_max": 180, "context": { "style": "neutral cushioned daily trainer" }, "max_results": 3 }' ``` ### Only sale items ```json theme={null} { "query": "cast iron dutch oven", "on_sale_only": true, "merchants": ["Walmart"], "max_results": 5 } ``` ## Show results Show `name`, `brand`, `merchant`, `price` (with `regular_price` struck through when `on_sale` is `true`), and `image_url`. Link the full card to `click_url`. In chat surfaces, `click_url` alone is sufficient, because it shows as a card. Do not change or shorten `click_url`. Do not link to the merchant page directly. The redirect attributes the sale to your platform. ## Buy for the user When a product has a `checkout` value, your agent can complete the purchase itself. It does not have to give the user a link. Call the merchant's `endpoint` (an MCP server) directly, and send the `attribution` object without changes. When there is no click, this object attributes the order to your platform. Gravity never sees the payment. ```json theme={null} "checkout": { "protocol": "ucp", "endpoint": "https://www.allbirds.com/api/ucp/mcp", "continue_url": "https://www.allbirds.com/cart/32262292013136:1", "item_id": "gid://shopify/ProductVariant/32262292013136", "agent_profile": "https://index.trygravity.com/ucp/agent-profile.json", "attribution": { "referring_domain": "index.trygravity.com", "click_id_tag": "grclid", "click_id_value": "e92f37c0-e2a3-4ba0-8526-b93e783becd1" } } ``` This call creates a checkout. It does not charge the user. ```bash theme={null} curl -X POST https://www.allbirds.com/api/ucp/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "create_checkout", "arguments": { "meta": { "ucp-agent": { "profile": "https://index.trygravity.com/ucp/agent-profile.json" } }, "checkout": { "currency": "USD", "line_items": [{ "quantity": 1, "item": { "id": "gid://shopify/ProductVariant/32262292013136" } }], "attribution": { "referring_domain": "index.trygravity.com", "click_id_tag": "grclid", "click_id_value": "e92f37c0-e2a3-4ba0-8526-b93e783becd1" } } } } }' ``` The merchant returns a checkout and sends `attribution` back. The status is `"requires_escalation"` until you supply the buyer, delivery, and payment. Continue with `update_checkout` and `complete_checkout`, as the merchant's UCP profile at `https://STORE_DOMAIN/.well-known/ucp` describes. Or give the user `checkout.continue_url` to finish in a browser. > [!WARNING] > > `complete_checkout` charges the user's payment method. Get the user's explicit consent before you call it. Attribution rules: * Always send `attribution` exactly as the Index returned it. Do not make your own click IDs. * If the merchant rejects the call, or if `checkout` is `null`, use `click_url`. ## Select `/search` or `/shop/search` A router of three lines in the agent prompt is usually sufficient. Over REST, the two verticals are separate endpoints. ```text theme={null} If the user asks for software, an API, hosting, a database, or anything they would sign up for, call POST /search. If they ask for a physical product or something delivered to them, call POST /shop/search. Otherwise, answer normally. ``` Over MCP, only the Python version of the [Gravity Index MCP server](/gravity-index/mcp) can search products. It uses one tool, `gravity_index_search`, with `vertical: "consumer"`. The npm package `@gravity-ai/index-mcp` searches developer services only. If your platform serves only one audience, connect only that endpoint. There is no penalty if you do not use the other vertical. ## Errors | Status | Description | | - | - | | `401` `platform_api_key required` | The request has no key in the header or the body. | | `403` `valid platform_api_key required` | The key is not valid. | | `403` `publisher_not_approved` | The publisher account is blocked. This is the same rule as for production ads. | | `413` `metadata is too large` | `metadata` is more than 20,000 characters as JSON. | | `422` | Validation failed. For example: no `query` and no `context.messages`, `query` is more than 1,000 characters, `max_results` is not 1 to 25, more than 20 brands or merchants, more than 10 colors, sizes, or categories, an unknown category, or `external_session_id` is more than 200 characters. | | `429` `daily_request_limit_reached` | Your account has a daily request limit, and you used it all. Try again after the `Retry-After` header (seconds). The limit resets at midnight UTC. | # Publisher performance Source: https://docs.trygravity.com/gravity-index/publisher-performance GET https://api-interface.trygravity.com/index/attributions?side=publisher Read the numbers of your Index dashboard with your publisher API key. This endpoint gives you the numbers of the **Index** page of your Gravity dashboard as JSON. You get revenue, Index calls, recommendations, clicks, click-through rate, conversions, conversion rate, and the daily series of the chart. You can ask for any window in the last 90 days. The API and the dashboard use one shared query, so they show the same totals for the same UTC date range. > [!NOTE] > > Days are in **UTC**. The dashboard also uses UTC days. A range that matches the dashboard's date picker returns the same totals as its cards. ## Authentication Send your publisher API key in the `X-API-Key` header. This is the key that you use for [`POST /search`](/gravity-index/search). The endpoint also accepts the header `X-Platform-Api-Key`. **X-API-Key** `string` *required* Your publisher API key. The endpoint finds the publisher from the key. There is no publisher parameter. If the request also has an `Authorization` header, the endpoint uses that header and returns the dashboard payload, not the payload on this page. Do not send both. ## Request **side** `string` (default: publisher) Must be `publisher` (the default). Any other value returns `422`. **start\_date** `string` The first UTC day, `YYYY-MM-DD`. The default is 29 days before `end_date` (a 30-day window). Must be on or after `available_from` (today minus 90 days). **end\_date** `string` The last UTC day, `YYYY-MM-DD`, inclusive. The default is today (UTC). Must not be in the future, before `start_date`, or before `available_from`. With an API key, the endpoint accepts only `side`, `start_date`, and `end_date`. Any other parameter (for example `limit` or `status`) returns `422`. Those parameters are for the dashboard session, which gets a different payload from this route. ## Response **publisher\_id** `string` Your publisher ID. **timezone** `string` Always `UTC`. **start\_date** `string` The first day in the window (`YYYY-MM-DD`). **end\_date** `string` The last day in the window (`YYYY-MM-DD`). **available\_from** `string` The earliest day that you can ask for (today minus 90 days). **summary** `object` The totals for the window. These are the dashboard's cards. **revenue\_cents** `integer` Your payout in cents from conversions with the status approved, locked, payable, paid, or billed. **index\_calls** `integer` The number of Index searches that your platform made. **recommendations** `integer` The number of searches that returned a recommendation. **clicks** `integer` The number of searches whose tracked link the user clicked. Each search counts one time, on the day of the click. **click\_through\_rate** `number | null` `clicks` divided by the number of searches that returned a tracked link. `null` when no search in the window returned a link. **conversions** `integer` The number of approved conversions that Gravity attributed to you. **conversion\_rate** `number` `conversions` divided by `recommendations`. `0` when there were no recommendations. **daily** `array` One item for each UTC day from `start_date` to `end_date`, oldest first. Days with no activity have zeros. **date** `string` `YYYY-MM-DD` **index\_calls** `integer` **recommendations** `integer` **clicks** `integer` **conversions** `integer` > [!NOTE] > > `index_calls` is usually higher than `recommendations`. Searches with no match, and consumer product searches, also count as calls. For a publisher on CPA-only programs, this difference is normal. It does not mean that Gravity lost calls. ## Errors | Status | Body | Description | | - | - | - | | `401` | `Invalid API key` | The key does not belong to a publisher. | | `403` | `publisher_not_approved` | The publisher account is blocked. | | `403` | `index_not_enabled` | The publisher has no active Index profile yet. This includes pending accounts. | | `422` | A message that names the problem, for example `start_date cannot be after end_date`. A date that the endpoint cannot parse returns a list of validation errors. | A bad date, a window outside the last 90 days, a `side` other than `publisher`, or a parameter that the endpoint does not accept. | | `429` | `Too many requests. Please try again later.` | More than 60 requests in one minute for this publisher. Wait for the number of seconds in the `Retry-After` header, then try again. | ## Example ```bash theme={null} curl "https://api-interface.trygravity.com/index/attributions?side=publisher&start_date=2026-10-01&end_date=2026-10-07" \ -H "X-API-Key: YOUR_API_KEY" ``` ```json theme={null} { "publisher_id": "2b1f4d4e-9c8a-4f0e-b1b2-5c1b7a3f9d10", "timezone": "UTC", "start_date": "2026-10-01", "end_date": "2026-10-07", "available_from": "2026-07-23", "summary": { "revenue_cents": 1400, "index_calls": 812, "recommendations": 403, "clicks": 57, "click_through_rate": 0.1414, "conversions": 2, "conversion_rate": 0.00496 }, "daily": [ {"date": "2026-10-01", "index_calls": 120, "recommendations": 61, "clicks": 9, "conversions": 0}, {"date": "2026-10-02", "index_calls": 98, "recommendations": 47, "clicks": 6, "conversions": 1}, {"date": "2026-10-03", "index_calls": 0, "recommendations": 0, "clicks": 0, "conversions": 0}, {"date": "2026-10-04", "index_calls": 141, "recommendations": 70, "clicks": 11, "conversions": 0}, {"date": "2026-10-05", "index_calls": 160, "recommendations": 82, "clicks": 12, "conversions": 1}, {"date": "2026-10-06", "index_calls": 150, "recommendations": 74, "clicks": 10, "conversions": 0}, {"date": "2026-10-07", "index_calls": 143, "recommendations": 69, "clicks": 9, "conversions": 0} ] } ``` To get the last 30 days to today, leave out both dates. One request each day is sufficient for most uses. The limit is 60 requests in one minute for each publisher. # Search Source: https://docs.trygravity.com/gravity-index/search POST https://index.trygravity.com/search Find the best developer service for a task. `POST /search` finds the best developer service for a task. An LLM compares the query with the catalog. It returns one recommendation and the reasons why the service fits. A matched response has the **reasoning** and, when the service has a destination URL, a **tracked click link** that the agent gives to the user. Send your `external_session_id`, and the Index uses the conversation as private context. See [Conversation context](/gravity-index/conversation-context). > [!NOTE] > > For physical products (shoes, cookware, electronics), use [`POST /shop/search`](/gravity-index/products). That vertical returns a ranked list. `/search` uses the product catalog only when `vertical` is `consumer`, when you send `filters`, or when `vertical` is `auto` and Gravity turned on consumer search for your account. In those cases the response is a `/shop/search` response. ## Authentication Send your publisher API key in the `X-API-Key` header or in the `platform_api_key` body field. One of the two is required. **X-API-Key** `string` Your publisher API key. You can send `platform_api_key` in the body instead. ## Request **query** `string` *required* What the user needs, in natural language, up to 1,000 characters. For example: "serverless database with free tier" or "auth for Next.js with SSO". You can leave it empty only if `context.messages` contains the conversation. Then the Index uses the last user turn as the query. **search\_id** `string` The `search_id` of an earlier response. Send it with a follow-up question, and the Index uses the earlier search as context. The value must be a UUID. The Index ignores other values and runs a new search. **context** `object` Project context for the Index to consider, for example `{"framework": "nextjs", "language": "typescript", "constraints": ["free tier", "EU region"]}`. The format is free. Put facts about the user's stack here, not in `query`. `context.messages` (`[{role, content}]`) can carry the conversation. The Index uses the last 12 turns. **platform\_api\_key** `string` Your publisher API key. Send this field or the `X-API-Key` header. **max\_options** `integer` (default: 5) 1 to 10. Sets the upper limit for the `options` list. The list holds the recommendation and at most three alternatives. **vertical** `string` (default: auto) `developer`, `consumer`, or `auto`. With `auto`, the Index sends requests for retail products to the product catalog only if Gravity turned on consumer search for your account. Otherwise it uses the developer catalog. **filters** `object` Hard filters for the product catalog: `brands`, `merchants`, `price_min`, `price_max`, `currency`, `sizes`, `colors`, `gender`, `categories`, `on_sale_only`. If you send filters with `auto`, the Index uses the product catalog. See [Product search](/gravity-index/products) for the rules. **external\_session\_id** `string` Your stable chat or session ID, up to 256 characters. Send it on every call. If Gravity has a summary of that conversation, the Index uses it as private context (see [Conversation context](/gravity-index/conversation-context)). Gravity also uses it for attribution and payout matches. The response never contains it. **external\_user\_id\_hash** `string` A hash of your user ID. Send this in place of a raw user ID when you can. **external\_user\_id** `string` A raw user ID (optional). Gravity stores only a SHA-256 hash of it. **hashed\_email** `string` The SHA-256 hash of the normalized user email (`email.strip().lower()`), if you have it. This is the canonical field. It is the same as in the [ad request](/ai-platforms/request-ads). The Index also accepts the old name `email_hash`. **phone\_hash** `string` The SHA-256 hash of the user phone number (digits only), if you have it. **metadata** `object` Publisher metadata that is not sensitive, for example the surface, workspace, plan, or placement. Gravity stores it with the search for attribution and for problem analysis. The LLM does not see it. The JSON must be 20,000 characters or fewer. **monetization** `object` Optional private controls for the order of results. Send `{"mode": "boost_cpa"}` for relevance-first results that give a small boost to services with an active CPA campaign. Send `{"mode": "off"}`, or leave out the field, for relevance only. The response never shows prices or payouts. ## Response **api\_version** `string` The response contract version, now `"2026-05-23"`. **vertical** `string` `"developer"` for this response shape. **search\_id** `string` A unique ID for this search. Send it back for a follow-up question. **query** `string` The query that the Index used. **recommendation** `object | null` The recommended service, or `null` when no service fits. **service\_id** `string` The catalog ID of the service. **slug** `string` A URL-safe identifier. **name** `string` The service name. **category** `string` For example `Database`, `Auth`, or `AI`. **description** `string` What the service does. **website\_url** `string` The service website. **docs\_url** `string` The service documentation. **click\_url** `string | null` The tracked link. The same value as the top-level `click_url`. **grclid** `string | null` The Gravity click ID. The same value as the top-level `grclid`. **checkout** `object | null` A pointer for machine payment, or `null`. When present, it is `{protocol: "mpp", endpoint, network_id, attribution: {click_id_tag: "grclid", click_id_value}}`. The agent pays the merchant directly at `endpoint` (which already contains `grclid`) with a Stripe Link credential. The value is `null` when the listing has no payment link. See [Agent Purchases](/gravity-index/link-purchases). **options** `object[]` The recommendation and the accepted alternatives, in order. Each item has `service_id`, `slug`, `name`, `category`, `description`, `website_url`, `docs_url`, `click_url`, and `grclid`. `best_for` and `tradeoff` are always `null` now. **reasoning** `string` Why this service is the best fit for the query, in natural language. **install** `object | null` Setup details for the recommended service. Agents can ignore this field and show only the recommendation. **summary** `string` A one-line description of the setup. **steps** `array` Setup steps in order, with commands, files, and user actions. **env\_vars** `array` The environment variables that the integration needs. **notes** `string` Configuration notes for this context. **credential\_request** `object | null` Instructions for the credentials that the user must get. The value is `null` when there is no click link. **setup\_url** `string` The tracked setup link that the user opens. **required\_env\_vars** `array` The environment variables that the user must paste back. **user\_message** `string` An instruction for the user. Show it as it is. **agent\_instruction** `string` An instruction for the agent: how to stop and continue the setup. **grclid** `string` The Gravity click ID for attribution. **click\_url** `string | null` The tracked short link that the user opens to sign up with the recommended service. **grclid** `string | null` The Gravity click ID (a UUID) for the tracked click link. **alternatives** `object[]` At most three other services that also fit. Each item is `{slug, reason}`. **unmatched\_slug** `string | null` A service name that the LLM suggested but that is not in the catalog. When this field has a value, `recommendation` is `null`. Ask the user before you act on it. ## Errors | Status | Body | Description | | - | - | - | | `401` | `platform_api_key required` | The request has no key in the header or the body. | | `403` | `valid platform_api_key required` | The key is not valid. | | `403` | `publisher_not_approved` | The publisher account is blocked. | | `413` | `metadata is too large` | `metadata` is more than 20,000 characters as JSON. | | `422` | A list of validation errors | For example: no `query` and no `context.messages`, `query` is more than 1,000 characters, or `max_options` is out of range. | | `429` | `{"error": "daily_request_limit_reached", "reset_at": "..."}` | Your account has a daily request limit, and you used it all. Try again after the `Retry-After` header (seconds), at `reset_at` (midnight UTC). | | `502` | `Search failed` | The Index could not get an answer from the LLM. Try again. | ## Examples ### Basic search ```bash theme={null} curl -X POST https://index.trygravity.com/search \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_API_KEY" \ -d '{ "query": "I need a serverless database with a free tier", "external_session_id": "session_123", "external_user_id_hash": "sha256_user_id", "hashed_email": "sha256_email", "metadata": {"surface": "chat"} }' ``` The response looks like this (shortened): ```json theme={null} { "api_version": "2026-05-23", "vertical": "developer", "search_id": "238c2e03-347b-438e-8b52-f9cacee31ebe", "query": "I need a serverless database with a free tier", "recommendation": { "service_id": "6f1c2b0e-8d4a-4c7e-9a51-3b2e7d9f0a14", "slug": "neon", "name": "Neon", "category": "Database", "description": "Serverless Postgres with branching, autoscaling, and bottomless storage.", "website_url": "https://neon.tech", "docs_url": "https://neon.tech/docs", "click_url": "https://index.trygravity.com/go/9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b", "grclid": "9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b", "checkout": null }, "options": [ { "service_id": "6f1c2b0e-8d4a-4c7e-9a51-3b2e7d9f0a14", "slug": "neon", "name": "Neon", "category": "Database", "description": "Serverless Postgres with branching, autoscaling, and bottomless storage.", "website_url": "https://neon.tech", "docs_url": "https://neon.tech/docs", "best_for": null, "tradeoff": null, "click_url": "https://index.trygravity.com/go/9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b", "grclid": "9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b" } ], "reasoning": "Neon is the best fit because it is serverless Postgres with a free tier, works with edge functions, and has database branches for preview deployments.", "install": { "summary": "Provision a serverless Postgres database", "steps": [ {"step": 1, "action": "Install the Neon driver", "command": "npm install @neondatabase/serverless"}, {"step": 2, "action": "Create a db client", "file": "lib/db.ts"}, {"step": 3, "action": "Add connection string", "file": ".env.local"}, {"step": 4, "action": "Get credentials", "user_action": "Go to https://console.neon.tech, then Connection Details"} ], "env_vars": ["DATABASE_URL"], "notes": "" }, "credential_request": { "setup_url": "https://index.trygravity.com/go/9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b", "grclid": "9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b", "required_env_vars": ["DATABASE_URL"], "user_message": "Open the setup link for Neon, create or find the required credentials, then paste the values for `DATABASE_URL` back into the agent so it can finish the integration.", "agent_instruction": "Pause for any missing credentials, ask the user to open setup_url, then continue once they paste values back." }, "click_url": "https://index.trygravity.com/go/9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b", "grclid": "9b3f6a1e-2c4d-4e8f-a7b6-1d2c3e4f5a6b", "alternatives": [], "unmatched_slug": null } ``` ### Follow-up question ```bash theme={null} curl -X POST https://index.trygravity.com/search \ -H "Content-Type: application/json" \ -H "X-API-Key: YOUR_API_KEY" \ -d '{ "search_id": "238c2e03-347b-438e-8b52-f9cacee31ebe", "query": "Does it support database branching for preview deploys?" }' ``` The Index uses the earlier search as context for the answer. # Introduction Source: https://docs.trygravity.com/index Who Gravity is for, and where to start. Gravity is the ad network for AI. Brands reach people inside conversations with LLMs, agents, chatbots, and AI search. The apps that host those conversations earn from the ads they show. ## Who are these docs for? > **[Advertisers](/advertisers/quickstart)** > > Reach people in AI conversations and measure the results. > **[Publishers](/ai-platforms/quickstart)** > > Earn from your AI app with ads that fit the conversation. ## Advertisers * [Quickstart](/advertisers/quickstart): billing, tracking, and your first campaign. * [Tracking](/advertisers/quickstart#step-2-set-up-tracking): track conversions with the browser pixel and the Conversions API (CAPI). * [Campaigns](/advertisers/campaigns): objectives, bids, budgets, and targeting. * [Dashboard API](/advertisers/dashboard-api) and [MCP server](/advertisers/dashboard-mcp): manage campaigns from code or an AI agent. ## Publishers * [Quickstart](/ai-platforms/quickstart): show your first test ad. * [Integration guide](/ai-platforms/integration-guide): placements, tracking, and error handling. * [Go live](/ai-platforms/going-live): checks before production traffic. * [Gravity Index](/gravity-index/introduction): let your agent recommend products and services, and earn from it. ## More * [How it works](/introduction/how-it-works) * [API reference](/engine/introduction) * [Changelog](/changelog) # Core concepts Source: https://docs.trygravity.com/introduction/concepts The Gravity glossary: one short definition for each term that the docs use. This page defines the terms that the Gravity docs use. Other pages link to the definitions here. ## Roles **AI platform.** An app that shows Gravity ads next to a conversation with a model, for example a chat app, a coding assistant, or an AI search product. The API and the SDKs also call an AI platform a "publisher". An AI platform gets a share of the revenue from the ads it shows. **Advertiser.** A company that runs ad campaigns on the Gravity network. An advertiser sets the budget, the ad content, and the targets, and pays for impressions. ## Ad serving **Ad request.** One call to `POST /api/v1/ad`. It sends the recent conversation messages, a session ID, and one or more placements. See [Contextual ads](/engine/contextual-ads). **Contextual matching.** Gravity selects ads mainly from the content of the current conversation. Campaign targeting, such as location and advertiser audiences, can also apply when the user's consent allows it. The engine compares the recent messages with active campaigns, runs an auction, and writes ad text that fits the conversation. **Auction.** The step where the engine compares the campaigns that match a request and selects the ad to show. **Relevancy.** A number from 0.0 to 1.0 that sets the minimum match quality for an ad. A lower value returns more ads with weaker matches. The SDKs send `0.2` by default. **No-fill.** The result when no ad matches a request. The API returns HTTP `204` with an empty body, and the SDKs return an empty ads list. **Test ad.** An ad that the engine returns when the request has `testAd: true`. Test ads do not bill. The SDKs send test requests until you set `production` to `true`. ## Placements A **placement** is a named slot in your UI where an ad can show. Each ad request names one or more placements, and each impression belongs to one placement. Each placement in a request has two values: * `placement`: the slot type. The standard types are `above_response`, `below_response`, `inline_response`, `left_response`, `right_response`, `search_result`, `top_page`, `bottom_page`, `left_page`, `right_page`, and `center_page`. * `placement_id`: your stable ID for the slot. Each `placement_id` must be unique in one request. Placements give you per-slot reports in the dashboard, and they let Gravity run tests on one slot at a time. Register each slot in your Gravity dashboard under **AI platforms → Placements**. Then send the slot's `placement_id` in your ad request. The `placement_id` does not create a placement. If your account has registered placements, the engine can drop slots with an unregistered `placement_id` from the request. See the [Placements guide](/ai-platforms/placements). ## Measurement and billing **Impression.** One view of an ad. An impression counts when your app loads the ad's `impUrl` at the time the ad becomes visible. The official SDK components do this for you. **Click.** A user tap on an ad. Always send the user to the ad's `clickUrl`, not to `url`, so that Gravity can record the click. **CPM.** Cost per mille: the price of 1,000 impressions. Publishers are paid on CPM, and advertisers can buy on CPM. ## Sessions A **session** is one conversation. Send the same `sessionId` for all ad requests in one chat thread. Gravity uses the session for frequency limits, experiment groups, and reports. ## Ad designs **Server-rendered design.** A layout for a placement that Gravity sends with the ad in the `renderer_spec` field. The official SDK components draw it for you, so Gravity can change the design of a placement without a code change in your app. See [Server-rendered ads](/sdks/server-rendered-ads). **Experiment.** A test that Gravity runs on ad designs or formats to find what works best for your traffic. The SDK components handle experiments with no code from you. See [Experiments](/ai-platforms/experiments). ## Pixels The **Gravity pixel** is a small first-party script. It has two uses: > **[On an AI platform](/ai-platforms/pixel)** > > You install it on your site or in your in-app browser. It links a click that opens in a WebView back to the impression that caused it. > **[On an advertiser landing page](/advertisers/pixel)** > > The advertiser installs it on their site. It sends a conversion event when a user completes an action, for example a purchase or a signup. Both pixels are first-party. They do not share data across unrelated sites. ## Privacy **Consent.** The optional `consent` object in an ad request carries the privacy choices of the user (GDPR, US privacy, and Global Privacy Control). When the user does not give consent, Gravity serves contextual ads only and does not process user identifiers. Optional hashed identifiers (email and phone) improve attribution. See [Data quality](/ai-platforms/request-ads#data-quality) for what to send and how to hash it. # How it works Source: https://docs.trygravity.com/introduction/how-it-works The request flow, the auction, and how Gravity matches ads to a conversation. This page follows one user message from your client to the ad that appears next to the answer. For the terms, see [Core concepts](/introduction/concepts). ## The request flow ```mermaid theme={null} sequenceDiagram participant Client as Your client participant Server as Your server participant LLM as LLM provider participant Gravity as Gravity engine Client->>Server: Message + gravity_context par Model call Server->>LLM: Chat completion LLM-->>Server: Streamed tokens and Ad request Server->>Gravity: POST /api/v1/ad Gravity-->>Server: Matched ad end Server-->>Client: Tokens, then the ad ``` 1. The user sends a message. Your client calls `gravityContext()` to collect the session and device data, and sends it to your server with the message. 2. Your server starts the ad request with `gravity.getAds()` at the same time as the model call. In the SDK examples, the server streams the model response first and waits for the ad only at the end, so the ad request does not hold up the answer. 3. The Gravity engine compares the conversation with active campaigns, runs an auction, and returns the winning ad. 4. Your server appends the ad to the stream, and your client shows it. The engine gives each ad request up to 4 seconds. If it can't finish in that time, it returns `204 No Content` (no ad), and the SDKs return an empty `ads` list. The SDKs also apply their own client timeout, which you can change. ## Contextual matching Gravity matches on context, not keywords. ```mermaid theme={null} flowchart LR M[Messages] --> C[Topic match] C --> F[Relevancy threshold] F --> A[Auction] A --> W[Ad] ``` | User asks | Matching campaign | Ad | | - | - | - | | "How do I deploy a serverless Postgres database?" | A managed Postgres provider | A short line about its free tier, written for this question | What the engine uses: | Signal | Used for | | - | - | | Recent messages | Topic match and the ad text ([generative ad groups](/advertisers/campaigns)) | | Location and advertiser audiences | Campaign targeting | | User ID | Frequency limits and attribution | | Consent (`consent`, GPC) | Without consent, contextual-only ads and no user identifiers ([details](/engine/contextual-ads)) | Publishers set the [relevancy threshold](/ai-platforms/request-ads#relevancy-tradeoff): a higher value shows fewer, closer matches. ## How publishers earn Publishers earn on a CPM basis: revenue is counted per impression and reported per 1,000 impressions. The rate is not fixed. Your effective CPM (eCPM) depends on what advertisers pay for the ads you show, which [Payouts and analytics](/ai-platforms/payouts) explains. Gravity counts an impression when the ad's `impUrl` is loaded. The SDK components load it for you once the ad is on screen (for example, the React components wait until the ad enters the viewport). If you draw ads yourself, load `impUrl` once when you show the ad. ## Next steps Build the integration with the [publisher quickstart](/ai-platforms/quickstart), or look up a term in [Core concepts](/introduction/concepts). # Overview Source: https://docs.trygravity.com/introduction/overview How Gravity connects advertisers, AI apps, and their users. Gravity is the ad network for AI. It matches ads to conversations, not keywords. ```mermaid theme={null} flowchart LR A[Advertiser] -- campaign --> G[Gravity] P[Publisher app] -- ad request --> G G -- matched ad --> P P -- shows ad --> U[User] ``` | Role | What you do | Start here | | - | - | - | | Advertiser | Add billing, set up conversion tracking, and launch campaigns. You pay per 1,000 impressions (CPM), or per conversion (CPA) where your account allows it. | [Advertiser quickstart](/advertisers/quickstart) | | Publisher | Request an ad from your server alongside your model call, and show it next to the answer. You earn on a CPM basis. | [Publisher quickstart](/ai-platforms/quickstart) | ## SDK or HTTP API Publishers can integrate with an SDK or call the HTTP API directly. | | SDK | HTTP API | | - | - | - | | Request ads | Yes | Yes | | Draw ads | Ready-made components | Your own code | | Impression tracking | Automatic | Load `impUrl` when you show the ad | | Design experiments | Automatic | Ask Gravity about custom renderers | SDKs: [JavaScript](/sdks/javascript) (`@gravity-ai/api`, `@gravity-ai/react`), [React Native](/sdks/react-native), [Python](/sdks/python), [iOS](/sdks/ios), and [Android](/sdks/android). The API is `POST https://server.trygravity.com/api/v1/ad`; see the [API reference](/engine/introduction). ## Where ads appear Chat apps, coding assistants and IDEs, consumer AI apps, and AI search: any product where a person talks to a model. For the terms used in these docs, see [Core concepts](/introduction/concepts). # Android SDK Source: https://docs.trygravity.com/sdks/android Add Gravity contextual ads to Android apps with the Kotlin SDK. Use the `ai.trygravity:gravity-sdk` library to request Gravity ads from your Android app and show them with Jetpack Compose or Android Views. This page describes version 1.2.1. For the terms on this page, see [Core concepts](/introduction/concepts). ## Before you start * Your app targets Android 7.0 (API 24) or later. * You have a Gravity API key. Get it from your dashboard under **Settings → Platform Settings**. > [!WARNING] > > Your API key is in the app package, and users can extract it. Use the key only for ad requests and feedback. ## Install Add the dependency to the `build.gradle.kts` file of your module: ```kotlin theme={null} dependencies { implementation("ai.trygravity:gravity-sdk:1.2.1") } ``` The SDK manifest declares the `INTERNET` permission, so you do not need to add it. The SDK uses OkHttp and Kotlin Coroutines. The Compose dependencies are `compileOnly`, so your app gets them only if it already uses Compose. ## Quick start 1. Make one `Gravity` instance with a `Context`, your API key, and `production = true`. 2. Call `gravity.getAds(...)` with the conversation messages, the session ID, and the placements. Run it in a coroutine at the same time as your model call. 3. Show the ad with `SpecAd` (Compose) or `GravityAdView` (Views). ```kotlin theme={null} import ai.trygravity.sdk.Gravity import ai.trygravity.sdk.models.* // 1. Make the client one time, for example in your ViewModel or Application val gravity = Gravity(context, apiKey = "YOUR_API_KEY", production = true) // 2. Request ads at the same time as your model call val messages = listOf( Message("user", "What's the best way to deploy a Postgres DB?"), Message("assistant", "Here are some options..."), ) viewModelScope.launch { val adDeferred = async { gravity.getAds( messages = messages, sessionId = "sess_abc123", userId = currentUser.id, placements = listOf(Placement.belowResponse()), ) } // Stream your model response... val result = adDeferred.await() // 3. Show the ad result.ads.firstOrNull()?.let { ad -> // Give the ad to SpecAd, or to your own UI } } ``` ## Constructor ```kotlin theme={null} Gravity( context: Context, apiKey: String = System.getenv("GRAVITY_API_KEY") ?: "", apiUrl: String = "https://server.trygravity.ai/api/v1/ad", feedbackUrl: String = "https://platform.trygravity.ai/ad-feedback", timeoutSeconds: Long = 4L, production: Boolean = false, relevancy: Double = 0.2, excludedTopics: List? = null, readCMPConsent: Boolean = true, honorAdTrackingLimit: Boolean = true, ) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `context` | `Context` | Required | The Android context. The SDK uses it to collect device data. | | `apiKey` | `String` | The `GRAVITY_API_KEY` environment variable | Your Gravity API key. | | `timeoutSeconds` | `Long` | `4` | The request timeout in seconds. | | `production` | `Boolean` | `false` | When `false`, Gravity returns test ads and does not bill. | | `relevancy` | `Double` | `0.2` | The minimum relevancy, from 0.0 to 1.0. | | `excludedTopics` | `List?` | `null` | Topics that ads must not match. | | `readCMPConsent` | `Boolean` | `true` | Read the consent that your IAB consent management platform (CMP) stored, and send it with each request. | | `honorAdTrackingLimit` | `Boolean` | `true` | If the device limits ad tracking, the request asks for contextual-only ads (`consent.gpc = true`). This needs `play-services-ads-identifier` in your app. | ## `getAds()` ```kotlin theme={null} suspend fun getAds( messages: List, sessionId: String, userId: String = "anonymous", placements: List, userIP: String? = null, hashedIdentity: HashedIdentity? = null, extraUserFields: Map? = null, extraDeviceFields: Map? = null, production: Boolean? = null, relevancy: Double? = null, consent: ConsentSignal? = null, usage: ModelUsage? = null, ): AdResult ``` The SDK collects device data (User-Agent, OS, device model, screen size, time zone, locale, and device type) and sends it with the request. The Device and Geography reports in your dashboard work with no extra code. `getAds()` never throws. On a failure, it returns `AdResult.EMPTY` (no ads, `status` `0`). `AdResult` has `ads`, `status`, `elapsedMs`, and `error`. ## Model usage `getAds()` accepts optional `usage = ModelUsage(model = "gpt-4.1-mini", inputTokens = 1830, outputTokens = 412)`. Add `cost` (USD, 0 to 100) only if you know what the call cost; it replaces the list-price estimate, and `0.0` means a free call. Invalid values are ignored. See [User earnings](/ai-platforms/user-earnings) for per-call reporting rules. Pass a stable `userId`, including for signed-out users. The default `"anonymous"` is excluded from per-user analytics. `extraUserFields` only accepts string values, so it can't carry the nested `user.tags` object. ## Placements ```kotlin theme={null} // Ready-made placements (the default ID is "main") Placement.belowResponse() // "below_response" Placement.inlineResponse() // "inline_response" Placement.aboveResponse() // "above_response" // Other placements Placement("right_response", "sidebar") ``` ## Show ads with Jetpack Compose ```kotlin theme={null} import ai.trygravity.sdk.views.SpecAd @Composable fun ChatScreen(response: String, ads: List, gravity: Gravity, sessionId: String) { Column { Text(response) ads.firstOrNull()?.let { ad -> SpecAd( ad = ad, gravity = gravity, sessionId = sessionId, ) } } } ``` `SpecAd` draws the placement design that Gravity manages, and shows a built-in card when no valid design is available. It records the impression and the click, shows the required ad label and images, and shows feedback buttons. | Parameter | Type | Description | | - | - | - | | `ad` | `AdResponse` | The ad from `getAds()`. | | `gravity` | `Gravity` | The client instance, for tracking. | | `sessionId` | `String` | Must be the same value as in the ad request. | | `modifier` | `Modifier` | The Compose modifier. Default `Modifier`. | | `theme` | `GravityTheme` | The colors for the ad. Default `GravityTheme()`. | | `showFeedback` | `Boolean` | Show thumbs-up and thumbs-down buttons under the ad. Default `true`. A tap sends the feedback and hides the buttons. A feedback tap does not count as an ad click. | `GravityAd` (in `ai.trygravity.sdk.views`) is the built-in Compose card without server-rendered designs. It takes `ad`, `gravity`, `sessionId`, `modifier`, and `showFeedback`. ## Show ads with Android Views For layouts in XML, use `GravityAdView`. It shows the built-in card, records the impression, and opens `clickUrl` on a tap. It does not show feedback buttons. ```kotlin theme={null} import ai.trygravity.sdk.views.GravityAdView val adView = GravityAdView( context = this, ad = ad, sdk = gravity, sessionId = sessionId, ) container.addView(adView) ``` ## Custom renderer If you show ads with your own UI, record the impression and the click yourself: ```kotlin theme={null} // Impression: call this one time, when the ad becomes visible gravity.trackImpression(ad) // Click: opens clickUrl (not url) gravity.handleClick(context, ad) ``` ## Hash PII To help attribution, send a hashed email and phone. The SDK hashes them on the device, so the raw values do not leave the device. ```kotlin theme={null} import ai.trygravity.sdk.hashPII val hashed = hashPII(email = user.email, phone = user.phone) val result = gravity.getAds( messages = messages, sessionId = sessionId, userId = user.id, placements = listOf(Placement.belowResponse()), hashedIdentity = hashed, ) ``` ## Ad feedback Use `sendFeedback()` if you make your own feedback UI. It sends the request in the background and ignores errors. ```kotlin theme={null} // Thumbs up gravity.sendFeedback( sessionId = sessionId, ad = ad, sentiment = Gravity.Sentiment.THUMBS_UP, ) // Thumbs down with reasons gravity.sendFeedback( sessionId = sessionId, ad = ad, sentiment = Gravity.Sentiment.THUMBS_DOWN, reasons = listOf("not_interested", "irrelevant"), ) ``` For the reason values, see [Ad feedback](/engine/ad-feedback). ## Full example: request ads during the model stream ```kotlin theme={null} import ai.trygravity.sdk.Gravity import ai.trygravity.sdk.models.* class ChatViewModel(application: Application) : AndroidViewModel(application) { private val gravity = Gravity(application, apiKey = "YOUR_API_KEY", production = true) val ads = MutableStateFlow>(emptyList()) val response = MutableStateFlow("") fun send(messages: List, sessionId: String, userId: String) { viewModelScope.launch { // Start the ad request val adDeferred = async { gravity.getAds( messages = messages, sessionId = sessionId, userId = userId, placements = listOf(Placement.belowResponse()), ) } // Stream the model response streamLLM(messages).collect { chunk -> response.update { it + chunk } } // Add the ads ads.value = adDeferred.await().ads } } } ``` ## FAQ > **Does the ad request block my model response?** > > No. Use `async` to start `getAds()` at the same time as your model stream. `getAds()` never throws. On a failure, it returns an empty list. > **Does the SDK collect the advertising ID (GAID)?** > > No. The SDK does not send the Google advertising ID. If the user gave consent, you can send it: `extraDeviceFields = mapOf("ifa" to gaid)`. > **Which Android versions does the SDK support?** > > Android 7.0 (API 24) and later. ## Troubleshoot | Problem | Cause and fix | | - | - | | `result.ads` is empty and `status` is `0` | The request got no response, for example because of a timeout or no network. Read `result.error`. | | `status` is `401` | The API key is missing or not valid. Check `apiKey`. | | `status` is `204` | No ad matched. This is a normal result. Hide the slot. | | Ads are always test ads | Set `production = true` in the constructor. | # iOS SDK Source: https://docs.trygravity.com/sdks/ios Add Gravity contextual ads to iOS, macOS, tvOS, and watchOS apps with the Swift SDK. Use the `GravitySDK` Swift package to request Gravity ads from your app and show them in SwiftUI. This page describes version 1.1.1. For the terms on this page, see [Core concepts](/introduction/concepts). ## Before you start * Your app targets iOS 15, macOS 12, tvOS 15, watchOS 8, or later. * You have a Gravity API key. Get it from your dashboard under **Settings → Platform Settings**. > [!WARNING] > > Your API key is in the app binary, and users can extract it. Use the key only for ad requests and feedback. ## Install 1. In Xcode, select **File → Add Package Dependencies**. 2. Enter the package URL: ``` https://github.com/Try-Gravity/gravity-swift-sdk ``` Or add the package to your `Package.swift`: ```swift theme={null} dependencies: [ .package(url: "https://github.com/Try-Gravity/gravity-swift-sdk", from: "1.1.1"), ] ``` The SDK uses only Apple system frameworks (Foundation, SwiftUI, CryptoKit, and AppTrackingTransparency). It adds no third-party dependencies. ## Quick start 1. Make one `Gravity` instance with your API key and `production: true`. 2. Call `gravity.getAds(...)` with the conversation messages, the session ID, and the placements. Run it at the same time as your model call. 3. Show the ad with `SpecAdView`. ```swift theme={null} import GravitySDK // 1. Make the client one time, for example in your App or ViewModel let gravity = Gravity(apiKey: "YOUR_API_KEY", production: true) // 2. Request ads at the same time as your model call let messages = [ Message(role: "user", content: "What's the best way to deploy a Postgres DB?"), Message(role: "assistant", content: "Here are some options...") ] async let adResult = gravity.getAds( messages: messages, sessionId: "sess_abc123", userId: currentUser.id, placements: [.belowResponse()] ) // Stream your model response... let result = await adResult // 3. Show the ad if let ad = result.ads.first { SpecAdView(ad: ad, gravity: gravity, sessionId: "sess_abc123") } ``` You see the ad under the response. `SpecAdView` records the impression when the ad appears. ## Constructor ```swift theme={null} Gravity( apiKey: String? = nil, apiUrl: String = "https://server.trygravity.ai/api/v1/ad", feedbackUrl: String = "https://platform.trygravity.ai/ad-feedback", timeoutSeconds: TimeInterval = 3.0, production: Bool = false, relevancy: Double = 0.2, excludedTopics: [String]? = nil, readCMPConsent: Bool = true, honorATT: Bool = true ) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `apiKey` | `String?` | The `GRAVITY_API_KEY` environment variable | Your Gravity API key. | | `timeoutSeconds` | `TimeInterval` | `3.0` | The request timeout in seconds. | | `production` | `Bool` | `false` | When `false`, Gravity returns test ads and does not bill. | | `relevancy` | `Double` | `0.2` | The minimum relevancy, from 0.0 to 1.0. | | `excludedTopics` | `[String]?` | `nil` | Topics that ads must not match. | | `readCMPConsent` | `Bool` | `true` | Read the consent that your IAB consent management platform (CMP) stored, and send it with each request. | | `honorATT` | `Bool` | `true` | Use the App Tracking Transparency status. If the user did not allow tracking, the request asks for contextual-only ads (`consent.gpc = true`). | ## `getAds()` ```swift theme={null} await gravity.getAds( messages: [Message], sessionId: String, userId: String = "anonymous", placements: [Placement], userIP: String? = nil, hashedIdentity: HashedIdentity? = nil, extraUserFields: [String: String]? = nil, extraDeviceFields: [String: Any]? = nil, production: Bool? = nil, relevancy: Double? = nil, consent: ConsentSignal? = nil, usage: ModelUsage? = nil ) -> AdResult ``` The SDK collects device data (User-Agent, OS, device model, screen size, time zone, locale, and device type) and sends it with the request. The Device and Geography reports in your dashboard work with no extra code. `getAds()` never throws. On a failure before an HTTP response, it returns an `AdResult` with no ads and `status` `0`. `AdResult` has `ads`, `status`, `elapsedMs`, and `error`. ## Model usage `getAds()` accepts optional `usage: ModelUsage(model: "gpt-4.1-mini", inputTokens: 1830, outputTokens: 412)`. Add `cost` (USD, 0 to 100) only if you know what the call cost; it replaces the list-price estimate, and `0` means a free call. Invalid values are ignored. See [User earnings](/ai-platforms/user-earnings) for per-call reporting rules. Pass a stable `userId`, including for signed-out users. The default `"anonymous"` is excluded from per-user analytics. `extraUserFields` only accepts string values, so it can't carry the nested `user.tags` object. ## Placements ```swift theme={null} // Ready-made placements (the default ID is "main") Placement.belowResponse() // "below_response" Placement.inlineResponse() // "inline_response" Placement.aboveResponse() // "above_response" // Other placements Placement(placement: "right_response", placementId: "sidebar") ``` ## Show ads in SwiftUI ```swift theme={null} import GravitySDK struct ChatView: View { let response: String let ads: [AdResponse] let gravity: Gravity let sessionId: String var body: some View { VStack { Text(response) if let ad = ads.first { SpecAdView( ad: ad, gravity: gravity, sessionId: sessionId ) } } } } ``` `SpecAdView` draws the placement design that Gravity manages, and shows a built-in card when no valid design is available. It records the impression and the click, shows the required ad label, and shows feedback buttons. | Parameter | Type | Description | | - | - | - | | `ad` | `AdResponse` | The ad from `getAds()`. | | `gravity` | `Gravity` | The client instance, for tracking. | | `sessionId` | `String` | Must be the same value as in the ad request. | | `theme` | `GravityTheme` | The colors for the ad. Default `.default`. | | `showFeedback` | `Bool` | Show thumbs-up and thumbs-down buttons under the ad. Default `true`. A tap sends the feedback and hides the buttons. A feedback tap does not count as an ad click. | `GravityAdView` is the built-in card without server-rendered designs. It takes `ad`, `gravity`, `sessionId`, and `showFeedback`. ## Custom renderer If you show ads with your own UI, record the impression and the click yourself: ```swift theme={null} // Impression: call this one time, when the ad becomes visible gravity.trackImpression(ad) // Click: opens clickUrl (not url) gravity.handleClick(ad) ``` ## Hash PII To help attribution, send a hashed email and phone. The SDK hashes them on the device, so the raw values do not leave the device. ```swift theme={null} import GravitySDK let hashed = hashPII(email: user.email, phone: user.phone) let result = await gravity.getAds( messages: messages, sessionId: sessionId, userId: user.id, placements: [.belowResponse()], hashedIdentity: hashed ) ``` ## Ad feedback Use `sendFeedback()` if you make your own feedback UI. It sends the request in the background and ignores errors. ```swift theme={null} // Thumbs up gravity.sendFeedback( sessionId: sessionId, ad: ad, sentiment: .thumbsUp ) // Thumbs down with reasons gravity.sendFeedback( sessionId: sessionId, ad: ad, sentiment: .thumbsDown, reasons: ["not_interested", "irrelevant"] ) ``` For the reason values, see [Ad feedback](/engine/ad-feedback). ## Full example: request ads during the model stream ```swift theme={null} import GravitySDK class ChatViewModel: ObservableObject { let gravity = Gravity(apiKey: "YOUR_API_KEY", production: true) @Published var ads: [AdResponse] = [] @Published var response = "" func send(messages: [Message], sessionId: String, userId: String) async { // Start the ad request async let adResult = gravity.getAds( messages: messages, sessionId: sessionId, userId: userId, placements: [.belowResponse()] ) // Stream the model response for await chunk in streamLLM(messages) { await MainActor.run { response += chunk } } // Add the ads let result = await adResult await MainActor.run { ads = result.ads } } } ``` ## FAQ > **Does the ad request block my model response?** > > No. Use `async let` to start `getAds()` at the same time as your model call. `getAds()` never throws. > **Does the SDK collect the IDFA?** > > No. The SDK does not read the advertising identifier. If the user gave App Tracking Transparency consent, you can send it: `extraDeviceFields: ["ifa": idfa]`. > **Which OS versions does the SDK support?** > > iOS 15, macOS 12, tvOS 15, watchOS 8, and later. ## Troubleshoot | Problem | Cause and fix | | - | - | | `result.ads` is empty and `status` is `0` | The request got no response, for example because of a timeout or no network. Read `result.error`. | | `status` is `401` | The API key is missing or not valid. Check `apiKey`. | | `status` is `204` | No ad matched. This is a normal result. Hide the slot. | | Ads are always test ads | Set `production: true` in the constructor. | # JavaScript and React SDKs Source: https://docs.trygravity.com/sdks/javascript Request Gravity ads from JavaScript or TypeScript, and show them in React. Use `@gravity-ai/api` on your server to request ads, and `@gravity-ai/react` in your client to show them. For the terms on this page, see [Core concepts](/introduction/concepts). ## Before you start * You have a Gravity API key. Get it from your dashboard under **Settings → Platform Settings**. * Your server runs Node.js (or another runtime with `fetch`), and your client uses React 17 or later. * You have a chat endpoint on your server that calls your model. ## Install 1. Install the packages: ```bash theme={null} npm install @gravity-ai/api @gravity-ai/react ``` 2. Set `GRAVITY_API_KEY` in your server environment. > [!WARNING] > > Do not reference `GRAVITY_API_KEY` in client code. A bundler can put the value into your public JavaScript. ## Step 1: Prepare the context in the client In your client, call `gravityContext()` and send the result to your server with the messages: ```ts theme={null} import { gravityContext } from '@gravity-ai/api'; const gravity_context = gravityContext({ sessionId: chatSession.id, user: { userId: currentUser.id }, }); await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages, gravity_context }), }); ``` `gravityContext()` collects the device data, the session, and the consent signal from the page. `user.userId` is required. In a browser with the [Gravity pixel](/ai-platforms/pixel), `sessionId` is optional: the SDK uses the pixel session. Send the complete `gravity_context` object to your server. ## Step 2: Request ads on the server Start the ad request at the same time as your model call: ```ts theme={null} import { Gravity } from '@gravity-ai/api'; const gravity = new Gravity({ production: true }); app.post('/api/chat', async (req, res) => { const { messages } = req.body; const adPromise = gravity.getAds(req, messages, [ { placement: 'below_response', placement_id: 'main' }, ]); const llmResponse = await callYourLLM(messages); const { ads } = await adPromise; res.json({ response: llmResponse, ads }); }); ``` `gravity.getAds()` reads `gravity_context` from `req.body`, adds the end user's IP address from the request headers, and sends the last two messages to Gravity. It never throws. On no-fill or on an error, `ads` is an empty array. The result is `{ ads, status, elapsed, requestBody, error }`. `status` is the HTTP status from Gravity, or `0` for a network error or a missing API key. ### Options Pass these options to `new Gravity()`. You can also pass them as the fourth argument of `getAds()` for one call. | Option | Default | Description | | - | - | - | | `apiKey` | `process.env.GRAVITY_API_KEY` | Your Gravity API key. | | `production` | `false` | Set to `true` for live ads. When `false`, Gravity returns test ads and does not bill. | | `timeoutMs` | `4000` | The request timeout in milliseconds. | | `relevancy` | `0.2` | The minimum relevancy, from 0 to 1. | | `excludedTopics` | None | Topics that ads must not match. | | `alwaysReturn` | `false` | For test ads only. Return a default test ad instead of no ad. | | `supportsSpec` | `true` | Set to `false` if you draw ads with your own code. | | `gravityApi` | `https://server.trygravity.ai/api/v1/ad` | The ad endpoint URL. | ### User tags and model usage Add `tags` to the user when you prepare context. Here, `completion` is the previous completed OpenAI-compatible reply in this same conversation, or absent on the first turn: ```ts theme={null} const gravity_context = gravityContext({ sessionId: chatSession.id, user: { userId: currentUser.id, tags: { plan: 'free' } }, }); const { ads } = await gravity.getAds( req, messages, [{ placement: 'below_response', placement_id: 'main' }], { usage: completion ? { model: completion.model, ...completion.usage } : undefined }, ); ``` Both are optional. If your provider returns the call's cost, such as OpenRouter's `usage.cost`, the spread carries it through and the dashboard uses it instead of a list-price estimate. See [Understand what your users earn you](/ai-platforms/user-earnings) for the rules. The spread also forwards nested `input_tokens_details.cached_tokens` (Responses) or `prompt_tokens_details.cached_tokens` (Chat Completions). OpenAI's input/prompt total already includes those cached tokens; no manual summing is needed. Alternatively send `cachedInputTokens` or `cached_input_tokens`, a subset of total input. Malformed cache counts are ignored and accepted counts are bounded by input. This isn't full Anthropic cache-read/write normalization. Report each reply only once. With next-turn reporting, the final reply isn't reported if no later message arrives; see [timing guidance](/ai-platforms/request-ads#report-model-usage). ## Step 3: Show the ad in React Give the ad to `GravityAd`: ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; export function ChatResponse({ response, ads }) { return (

{response}

{ads[0] && }
); } ``` You see the ad under the response. `GravityAd` draws the placement design, shows a built-in card when no design is available, records the impression and the click, and shows the ad label. Show the slot only when `ads` has an ad. Keep one stable `sessionId` for each conversation. ### Lead-form ads Some campaigns use a lead form. To let users submit a lead form, pass the session and an API key to `GravityAd`: ```tsx theme={null} ``` `sessionId` must be the same value as in the ad request. `GravityAd` uses `apiKey` only for lead-form submissions. > [!NOTE] > > The browser can read the `apiKey` prop. Send the value from your server with the ad response. Do not reference a server-only environment variable in client code. ## Custom renderer Use `GravityAd` if you can. If you draw ads with your own code, do these things: 1. Send `supportsSpec: false` in the `getAds()` options. 2. Load `ad.impUrl` once when the ad becomes visible. 3. Open `ad.clickUrl`, not `ad.url`, when the user clicks the ad. 4. Show a clear ad label. ## Troubleshoot | Problem | Cause and fix | | - | - | | `ads` is always empty and `status` is `0` | The API key is missing, or the network request failed. Set `GRAVITY_API_KEY` on the server, and read `error` in the result. | | `ads` is empty and `status` is `204` | No ad matched. To test your UI, set `alwaysReturn: true` with `production: false`. | | `status` is `401` | The API key is not valid. Copy it again from the dashboard. | | `gravityContext()` throws "requires user.userId" | Pass `user: { userId }`. | | `gravityContext()` throws "requires sessionId" | Pass `sessionId`, or load the Gravity pixel on the page. | | The dashboard shows your server as the device | Your server did not forward `gravity_context`. Send it from the client, and pass the original `req` to `getAds()`. | # Python SDK Source: https://docs.trygravity.com/sdks/python Request contextual ads from the Gravity API in a Python server. Use the `gravity-sdk` package to request Gravity ads from your Python server. This page describes version 0.1.0, the version on PyPI. For the terms on this page, see [Core concepts](/introduction/concepts). ## Before you start * Your server runs Python 3.10 or later. * You have a Gravity API key. Get it from your dashboard under **Settings → Platform Settings**. * Your web client calls `gravityContext()` from `@gravity-ai/api` and posts the result as `gravity_context`. See [Prepare the context](/sdks/javascript#step-1-prepare-the-context-in-the-client). ## Install 1. Install the package. Its only dependency is [httpx](https://www.python-httpx.org/). ```bash theme={null} pip install gravity-sdk ``` 2. Set `GRAVITY_API_KEY` in your server environment. ## Quick start Add these lines to the chat endpoint that streams your model response. The ad request runs at the same time as your model call, so it does not delay the response. ```diff theme={null} + import asyncio + from gravity_sdk import Gravity + gravity = Gravity(production=True) @app.post("/api/chat") async def chat(request: Request): body = await request.json() messages = body["messages"] + ad_task = asyncio.create_task( + gravity.get_ads(request, messages, [{"placement": "below_response", "placement_id": "main"}]) + ) async def event_stream(): async for token in stream_your_llm(messages): yield f"data: {json.dumps({'type': 'chunk', 'content': token})}\n\n" - yield f"data: {json.dumps({'type': 'done'})}\n\n" + ad_result = await ad_task + ads = [a.to_dict() for a in ad_result.ads] + yield f"data: {json.dumps({'type': 'done', 'ads': ads})}\n\n" return StreamingResponse(event_stream(), media_type="text/event-stream") ``` The last event of the stream contains the ads. When no ad matches, `ads` is an empty list. The SDK works with **FastAPI**, **Starlette**, **Django**, and **Flask**. ## Constructor ```python theme={null} Gravity(*, api_key=None, api_url="https://server.trygravity.ai/api/v1/ad", timeout=3.0, production=False, relevancy=0.2) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `api_key` | `str` | The `GRAVITY_API_KEY` environment variable | Your Gravity API key. | | `api_url` | `str` | `https://server.trygravity.ai/api/v1/ad` | The Gravity ad endpoint. | | `timeout` | `float` | `3.0` | The request timeout in seconds. | | `production` | `bool` | `False` | When `False`, Gravity returns test ads and does not bill. | | `relevancy` | `float` | `0.2` | The minimum relevancy, from 0.0 to 1.0. A lower value returns more ads with weaker matches. | The client keeps its HTTP connection pool between calls. To close it, use `async with Gravity() as g:` or call `await gravity.close()`. ## `get_ads()` ```python theme={null} await gravity.get_ads(request, messages, placements, *, production=None, relevancy=None, usage=None) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `request` | Framework request | Required | The request object of your server (FastAPI, Django, Flask, and others). | | `messages` | `list[dict]` | Required | The conversation messages: `[{"role": ..., "content": ...}]`. | | `placements` | `list[dict]` | Required | The ad slots, for example `[{"placement": "below_response", "placement_id": "main"}]`. | | `production` | `bool \| None` | `None` | Replaces the `production` value of the constructor for this call. | | `relevancy` | `float \| None` | `None` | Replaces the `relevancy` value of the constructor for this call. | | `usage` | `ModelUsage \| None` | `None` | Optional model, token counts and USD cost for this reply. | `get_ads()` reads `gravity_context` (session, user, and device) from the body of the request. It adds the end user's IP address from the request headers. It does not make a device object. Your browser client must call `gravityContext()` and post the result, and your backend must keep it in the body. > [!WARNING] > > Forward the `device` object from the client, with `device.ip` and `device.ua`, in each request. If the request has no end-user `ua` and `ip`, Gravity cannot run fraud and bot detection on it, and the Device and Geography reports do not include it. See [Device signals](/ai-platforms/request-ads#device-signals). `get_ads()` never raises an exception. On any failure, it returns an `AdResult` with an empty `ads` list. You can await it at the end of your stream with no risk to the stream. ## User tags and model usage Pass the usage object from one completed model call to `get_ads()` with the `usage` argument. Here, `completion` is the previous completed OpenAI-compatible reply in this same conversation, or `None` on the first turn. It's optional, and malformed values are ignored: ```python theme={null} result = await gravity.get_ads( request, messages, [{"placement": "below_response", "placement_id": "main"}], usage={"model": completion.model, **completion.usage.model_dump()} if completion else None, ) ``` If your provider returns the call's cost, such as OpenRouter's `usage.cost`, include it as `cost` and the dashboard uses it instead of a list-price estimate. `model_dump()` preserves nested `input_tokens_details.cached_tokens` (Responses) or `prompt_tokens_details.cached_tokens` (Chat Completions). OpenAI's input/prompt total already includes those cached tokens; no manual summing is needed. Alternatively send `cachedInputTokens` or `cached_input_tokens`, a subset of total input. Malformed cache counts are ignored and accepted counts are bounded by input. This isn't full Anthropic cache-read/write normalization. Report each reply only once. With next-turn reporting, the final reply isn't reported if no later message arrives; see [timing guidance](/ai-platforms/request-ads#report-model-usage). `user.tags` travel in the `gravity_context.user` your client posts, next to the user ID: `{ "gravity_context": { "user": { "userId": "u123", "tags": { "plan": "free" } } } }`. See [Understand what your users earn you](/ai-platforms/user-earnings) for the rules. ## Return types ```python theme={null} @dataclass class AdResult: ads: list[AdResponse] # Parsed ad objects status: int # 200, 204, an error status, or 0 (no response) elapsed_ms: str # for example "142" request_body: dict | None error: str | None @dataclass class AdResponse: ad_text: str title: str | None cta: str | None brand_name: str | None url: str | None favicon: str | None imp_url: str | None click_url: str | None placement: str | None placement_id: str | None ``` Both classes have a `to_dict()` method. It returns the camelCase JSON shape that the JavaScript renderers use. > [!NOTE] > > `AdResponse` in version 0.1.0 keeps only the fields above. It drops `renderer_spec`, `brandImage`, and the experiment fields. A React `GravityAd` that gets these ads shows the built-in text card. ## Messages that the SDK sends The SDK sends the last two conversation messages to Gravity for contextual matching. It keeps only messages with these roles: * `user`, `assistant`, `system`, `developer`, and `model` (the Gemini name for `assistant`) The SDK removes messages with other roles (for example `tool`, `function`, or `ipython`), because they usually contain structured data, not natural language. ## Experiments Gravity runs experiments on the server. Return the ad as usual and show it with the standard SDK component. You do not need experiment code. ## Troubleshoot | Problem | Cause and fix | | - | - | | `status` is `0` and `error` has a message | The request did not get a response, for example because of a timeout. Read `error`. | | `status` is `401` | The API key is not valid. Copy it again from the dashboard. | | The log shows "no API key provided" | The SDK found no key. Set `GRAVITY_API_KEY` or pass `api_key`. | | `status` is `204` | No ad matched. This is a normal result. Hide the slot. | | The dashboard shows your server as the device | The request body has no `gravity_context`. Send it from the client and keep it in the body. | # React Native SDK Source: https://docs.trygravity.com/sdks/react-native Show Gravity ads as native views in React Native apps. Use `@gravity-ai/react-native` to show Gravity ads as native views. Your server requests the ads with the [JavaScript SDK](/sdks/javascript), and your app shows them with `GravityAd`. For the terms on this page, see [Core concepts](/introduction/concepts). ## Before you start * Your app uses `react` 18.2 or later and `react-native` 0.76 or later. These are peer dependencies. * Your server requests ads with `@gravity-ai/api`. See [JavaScript and React SDKs](/sdks/javascript). The package does not use the DOM or `react-dom`. ## Install ```bash theme={null} npm install @gravity-ai/react-native ``` ## Request ads On your server, add `GRAVITY_RN_SPEC_OPTIONS` to the `getAds()` options. These options tell Gravity to send designs that the native renderer can draw (`supportsSpec: true` and `specCapabilities: "psp1"`). ```ts theme={null} import { GRAVITY_RN_SPEC_OPTIONS } from '@gravity-ai/react-native'; const result = await gravity.getAds(req, messages, placements, { ...GRAVITY_RN_SPEC_OPTIONS, }); ``` ## Show ads Give the ad from Gravity to `GravityAd`: ```tsx theme={null} import { Text } from 'react-native'; import { GravityAd, GravityThemeProvider } from '@gravity-ai/react-native'; export function ChatResponse({ response, ads }) { return ( {response} {ads[0] && } ); } ``` You see the ad under the response. `GravityAd` draws the placement design, shows a built-in card when no design is available, records the impression and the click, shows the ad label, and supports light and dark themes. From version 0.2.0, it also draws [image ads](/engine/contextual-ads#image-ads). The impression counts one time, when at least 50% of the ad is visible. ## Visibility in lists In a plain `ScrollView`, you do not need to do anything. The SDK measures visibility for you. In a virtualized list (`FlatList` or `SectionList`), use `onViewableItemsChanged` to find the visible rows. Then set `isViewable` to `true` when the list reports the ad row as visible: ```tsx theme={null} ``` The impression then counts when the list reports the ad as visible. If you measure visibility in your own `onScroll` handler, use the `measureVisibility` function from the `useAdTracking` hook. ## Themes `GravityThemeProvider` follows the color scheme of the device by default. To change the colors, give theme tokens: ```tsx theme={null} ``` The tokens are `background`, `foreground`, `card`, `cardForeground`, `primary`, `primaryForeground`, `muted`, `mutedForeground`, `accent`, `accentForeground`, `border`, and `radius`. To set light or dark mode, use the `mode` prop (`"light"`, `"dark"`, or `"auto"`). ## Troubleshoot | Problem | Cause and fix | | - | - | | The ad shows as a plain text card, not the placement design | The request did not include `GRAVITY_RN_SPEC_OPTIONS`. Add it to the `getAds()` options on the server. | | No image on image ads | Your version is older than 0.2.0. Upgrade `@gravity-ai/react-native`. | | Impressions do not count in a `FlatList` | Pass `isViewable` from `onViewableItemsChanged`. | # Server-rendered ads Source: https://docs.trygravity.com/sdks/server-rendered-ads Show the ad designs that Gravity manages, with the official SDKs. A server-rendered design is a layout for a placement that Gravity sends with the ad, in the `renderer_spec` field. The official SDK components draw it. Gravity can change the design of a placement with no code change and no SDK upgrade in your app. You install the SDK and add the ad component. ## React 1. Install the package: ```bash theme={null} npm install @gravity-ai/react ``` 2. Give the ad from your Gravity ad request to `GravityAd`: ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; export function ChatResponse({ response, ads }) { return (

{response}

{ads[0] && }
); } ``` For the request setup, see the [JavaScript and React SDK guide](/sdks/javascript). ## React Native 1. Install the package: ```bash theme={null} npm install @gravity-ai/react-native ``` 2. Add `GRAVITY_RN_SPEC_OPTIONS` to your ad request on the server. See [Request ads](/sdks/react-native#request-ads). 3. Give the ad to `GravityAd`: ```tsx theme={null} import { Text } from 'react-native'; import { GravityAd, GravityThemeProvider } from '@gravity-ai/react-native'; export function ChatResponse({ response, ads }) { return ( {response} {ads[0] && } ); } ``` For the request setup and for ads in lists, see the [React Native SDK guide](/sdks/react-native). ## iOS Give the ad from `getAds()` to `SpecAdView`: ```swift theme={null} SpecAdView( ad: ad, gravity: gravity, sessionId: sessionId ) ``` For the installation and the request setup, see the [iOS SDK guide](/sdks/ios). ## Android Give the ad from `getAds()` to `SpecAd`: ```kotlin theme={null} SpecAd( ad = ad, gravity = gravity, sessionId = sessionId, ) ``` For the installation and the request setup, see the [Android SDK guide](/sdks/android). ## What the SDKs do for you The official SDK components: * Draw the server-rendered design when it is compatible with the client. * Show a built-in card when no valid design is available. * Record impressions and clicks. * Show the required ad label. * Support light and dark themes. * Show feedback buttons (iOS and Android only). See [Ad feedback](/engine/ad-feedback#official-sdks). A custom design changes only how the ad looks. Tracking, attribution, and billing use the same URLs from the ad response. ## Image ads When an ad has a product image, Gravity sends an image card layout with it. The image card shows the image on one side and the text on the other side. These SDK versions draw it with no code change: `@gravity-ai/react` 1.3.0, `@gravity-ai/ui` 0.3.0, and `@gravity-ai/react-native` 0.2.0, and later versions. Older versions show the text card. Ads without an image always use the text card. To see an image ad, send a test request with `testAdFormat: "image"`. See [Image ads](/engine/contextual-ads#image-ads). ## Custom placement design To get a custom design for a placement, send your placement ID and the look you want to [support@trygravity.com](mailto:support@trygravity.com). Gravity sets up and checks the design before it goes live. # Terminal / CLI SDK Source: https://docs.trygravity.com/sdks/terminal Add Gravity ads and tracking to Node.js, Bun, and Deno terminal apps with @gravity-ai/api. Use `@gravity-ai/api` in a terminal app to track sessions and request ads. The package contains `gravityPixel()`, a pixel for terminal apps. For the terms on this page, see [Core concepts](/introduction/concepts). ## Before you start * Your app runs on Node.js, Bun, or Deno. * You have a Gravity API key and a pixel ID. Get them from your dashboard under **Settings → Platform Settings**. ## Install 1. Install the package: ```bash theme={null} npm install @gravity-ai/api ``` 2. Set `GRAVITY_API_KEY` in the environment of your app. You do not need a separate package for the pixel. `gravityPixel` is part of `@gravity-ai/api`. ## Overview 1. **Start the pixel.** `gravityPixel()` tracks sessions and visitors. Start it one time when your app starts. 2. **Prepare the context.** `gravityContext()` collects device data and session data. Give it the visitor and session IDs from the pixel, so that Gravity can link tracking to ad requests. 3. **Request ads.** `gravity.getAds(req, messages, placements)` reads the context and returns matched ads. Run it at the same time as your model call. ## Step 1: Start the pixel ```ts theme={null} import { gravityPixel } from '@gravity-ai/api'; const pixel = gravityPixel({ pixelId: 'YOUR_PIXEL_ID' }); await pixel.start(); ``` The pixel manages sessions, visitor IDs, and attribution, the same as the browser pixel (`gr-pix.js`). It keeps its state in local files, not in cookies. It saves the state when the process stops. > [!NOTE] > > `pixel.start()` adds handlers for `SIGINT` and `SIGTERM`. These handlers save the pixel state and then stop the process with exit code 0. | Parameter | Type | Default | Description | | - | - | - | - | | `pixelId` | `string` | None | **Required.** Your Gravity pixel ID. | | `appName` | `string` | `'terminal'` | The name of the app. Events use it as the page title. | | `eventEndpoint` | `string` | `'https://api.trygravity.ai/track/gr-events'` | The event endpoint URL. | | `sessionEndpoint` | `string` | `'https://api.trygravity.ai/track/gr-session'` | The session endpoint URL. | | `sessionTimeoutMinutes` | `number` | `30` | The session ends after this many minutes with no activity. | | `attrMaxAgeDays` | `number` | `180` | The maximum age of stored attribution data, in days. | | `sessionMaxPages` | `number` | `50` | The maximum number of entries in the invocation history. | | `hashIdentityFields` | `boolean` | `true` | Hash identity fields with SHA-256 before the pixel sends them. | | `storageDir` | `string` | `'~/.gravity'` | The directory for session and attribution data. | ## Step 2: Prepare the context ```ts theme={null} import { gravityContext } from '@gravity-ai/api'; const gravity_context = gravityContext({ sessionId: pixel.getSessionId()!, user: { userId: currentUser.id, gruid: pixel.getVisitorId(), grclid: pixel.getClickId(), graid: pixel.getGraid(), }, }); ``` `gravityContext()` finds the device data of the terminal (OS, time zone, and locale) for you. These parameters are required: | Parameter | Type | Description | | - | - | - | | `sessionId` | `string` | The session ID from `pixel.getSessionId()`. | | `user.userId` | `string` | Your stable ID for the user. | ## Step 3: Request ads ```ts theme={null} import { Gravity } from '@gravity-ai/api'; const gravity = new Gravity({ production: true }); const { ads } = await gravity.getAds( { body: { gravity_context }, headers: {} }, messages, [{ placement: 'inline_response', placement_id: 'main' }], ); ``` A terminal app has no incoming HTTP request, so pass an object with `body` and `headers` as the first argument. `gravity.getAds()` never throws. On any failure, `ads` is an empty array. ## Full example ```ts theme={null} import { gravityPixel, gravityContext, Gravity } from '@gravity-ai/api'; // 1. Start the pixel const pixel = gravityPixel({ pixelId: 'YOUR_PIXEL_ID' }); await pixel.start(); // 2. Make the ad client const gravity = new Gravity({ production: true }); // 3. Call this for each user query async function handleQuery(userId: string, messages: any[]) { const ctx = gravityContext({ sessionId: pixel.getSessionId()!, user: { userId, gruid: pixel.getVisitorId(), grclid: pixel.getClickId(), }, }); const { ads } = await gravity.getAds( { body: { gravity_context: ctx }, headers: {} }, messages, [{ placement: 'inline_response', placement_id: 'main' }], ); return ads; } ``` ## FAQ > **Does the ad request block my model response?** > > No. Start `gravity.getAds()` at the same time as your model call. It never throws. > **Where does the API key go?** > > Set `GRAVITY_API_KEY` as an environment variable. The `Gravity` class reads it. You can also pass it in the constructor: `new Gravity({ apiKey: process.env.MY_KEY })`. ## Troubleshoot | Problem | Cause and fix | | - | - | | `gravityContext()` throws "requires sessionId" | `pixel.getSessionId()` returned no value. Call `await pixel.start()` first. | | `gravityContext()` throws "requires user.userId" | Pass `user: { userId }`. | | `ads` is always empty and `status` is `0` | The API key is missing, or the request failed. Set `GRAVITY_API_KEY` and read `error` in the result. | | Your own `SIGINT` handler does not run to the end | The pixel handler stops the process. Do your cleanup before the pixel handler runs, or save your state often. |