# App install tracking Source: https://docs.trygravity.com/advertisers/app-installs Track iOS and Android app installs as Gravity conversions. ## Overview Track app installs back to the Gravity ad that drove them. Three integration paths: Gravity is a dynamic network partner in Adjust. Send us your Adjust tracker link and event tokens; we return a link with the Gravity callbacks attached. AppsFlyer, Kochava, Branch, Singular. Point a postback URL at Gravity. No code on your side. Your backend calls the Gravity Conversions API on install. Full control over data and timing. All three write to the same conversion pipeline. Click-through and view-through attribution are supported. The [Tracking CLI](/advertisers/tracking-cli) prepares app tracking with `gravity-tracking setup app --json`. Choose your backend or the MMP you already use; provider settings are configured in that provider's dashboard. *** ## How attribution works 1. User clicks a Gravity ad and receives a unique click identifier 2. User lands on your website or App Store page 3. User installs and opens your app 4. The install is reported to Gravity (via your backend or MMP) with the click identifier 5. Gravity attributes the install back to the originating campaign and ad Click-ID attribution does not require IDFA. Follow your app's consent and platform rules when collecting or sharing other identity signals. *** ## Option 1: Adjust Gravity is a **dynamic network partner** in Adjust. Adjust does not fire callbacks on its own: the callbacks are carried as parameters on the tracker link itself, and Adjust fires them when the install or event happens. There is no Data Sharing screen for Gravity, and Adjust will not prompt you for a Gravity API key. The callbacks carry no key either: they authenticate with the Gravity click ID Adjust echoes back. Send the **in-app event selected as your campaign's result**, alongside installs. Each in-app event needs its own callback on the link (see step 3). ### Before you start * Adjust access to Campaign Lab * The Adjust **event token** for every in-app event you want Gravity to see (Adjust → App → Events) * A live Gravity campaign with an ad group (you'll set the final link as its landing page in step 4) ### Setup In **Campaign Lab**, create a link for the Gravity network on the app you're advertising (iOS and Android are separate apps, so you'll have one link each). Copy the full click URL, an `https://app.adjust.com/...` or `https://.go.link/...` URL. Not the App Store URL. Send your Gravity contact the tracker link(s) plus the event token for each in-app event (trial, signup, purchase, ...). We attach the Gravity callbacks and send back the final link. If you'd rather do it yourself, the exact parameters are in [Build the link yourself](#build-the-link-yourself) below. The link you get back has one `install_callback=` parameter and one `event_callback_=` parameter per in-app event (on a `go.link` branded link these are `adj_install_callback=` and `adj_event_callback_=`). Each callback is a URL-encoded copy of the Gravity postback URL. It carries no API key: Gravity accepts the callback because `click_id` is a click ID Gravity itself issued. A callback whose `click_id` is unknown is rejected with `401` and nothing is recorded. Paste the **final link as the landing page on your Gravity ad group**. Ads have to point at the Adjust link: on every click Gravity appends its click ID as `click_id` (and `label`) to the link, Adjust stores it, and the callbacks echo it back so the install and events attribute to the ad. ### Build the link yourself Append these to your Adjust tracker link. Each callback value is the full Gravity URL, URL-encoded once. Gravity callback (same for install and events): ``` https://conversions.trygravity.com/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} ``` Do not put your Gravity API key in the callback. The tracker link is public (it's in every ad click), so anything in it is visible to anyone. Tracker link with callbacks attached (one `event_callback_` per event token): ``` https://app.adjust.com/abc123 ?install_callback= &event_callback_k9x2ab= &event_callback_p7m4qz= ``` On a branded / universal link (`https://.go.link/...`) Adjust requires the `adj_` prefix on every parameter, otherwise it ignores them: ``` https://yourapp.go.link/abc123 ?adj_install_callback= &adj_event_callback_k9x2ab= ``` Keep the `{...}` placeholders as-is; Adjust fills them when it fires the callback. `{click_id}` is the Gravity click ID we append on every click. `{event_name}` is what tells Gravity which in-app event fired; without it every event arrives as a generic `event`. ### Event mapping | Adjust `event_name` | Gravity event type | Why it matters | | - | - | - | | `install` (from `activity_kind`) | `app_install` | Baseline: volume only, no quality signal | | `session` / `re_engagement` | `app_open` | Retention signal | | `registration` / `signup` | `complete_registration` | First real intent signal; best default for freemium apps | | `purchase` / `in_app_purchase` | `in_app_purchase` | Optimizes toward revenue when `revenue` + `currency` are present | | `subscribe` | `subscribe` | Best signal for subscription apps | | `add_to_cart` | `add_to_cart` | Mid-funnel signal | | `lead` | `lead` | For lead-gen apps | Any other event name is passed through as-is (lowercased, spaces and dashes become `_`), so custom events like `trial_started` still land. They just won't be normalized into one of the types above. ### Verify Install the app from a real Gravity ad click and confirm the conversion appears in the Gravity dashboard as attributed. Follow [Adjust's callback test steps](https://help.adjust.com/en/article/test-your-callbacks-partner): append your device's advertising ID to the final link, click it through a Gravity ad so `click_id` is real, install and open the app, then trigger the events. The manual call below records an unattributed conversion; use a test account and keep its API key off the public tracker link: ```bash theme={null} curl "https://conversions.trygravity.com/mmp/postback/adjust\ ?click_id=test-click-123\ &activity_kind=event\ &event_name=trial_started\ &created_at=$(date +%s)\ &app_id=com.example.app\ &api_key=YOUR_API_KEY" ``` ```json theme={null} { "status": "ok", "conversion_id": "uuid", "attributed": false, "event_type": "trial_started", "provider": "adjust" } ``` `attributed: false` is expected for a fake click ID. Without `api_key`, the same fake click ID returns `401 Unknown click_id`. ### Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `401 Unknown click_id` / everything unattributed | Gravity's click ID isn't reaching the callback | The ad group landing page must be the Adjust link (`*.adjust.com` or `*.go.link`), and the callback must include `click_id={click_id}`. Clicks older than 7 days are also rejected | | Installs show up but nothing else | No `event_callback_` on the link | Add one `event_callback_=` per in-app event; Adjust only fires callbacks that are on the link | | Nothing arrives from a `go.link` link | Callback params lack the `adj_` prefix | Use `adj_install_callback=` / `adj_event_callback_=` on branded and universal links | | Every in-app event shows as `event` | Callback is missing `event_name={event_name}` | Add it to the callback and rebuild the link | | Purchases attribute but revenue is `0` | Callback is missing `revenue={revenue}¤cy={currency}` | Add them to the callback and rebuild the link | | `status: "duplicate"` | Same click ID + event type inside the dedup window | Expected for retried postbacks; no action needed | *** ## Option 2: Server-to-Server Best when you control the app backend and can capture the click identifier from the ad click URL. ### Step 1: Capture the click identifier on your landing page When a user arrives from a Gravity ad, the URL includes a `grclid` parameter: ``` https://yourapp.com/download?grclid=abc-123 ``` Capture and persist it server-side: ```javascript theme={null} const params = new URLSearchParams(window.location.search); const grclid = params.get('grclid'); // Store server-side (preferred) or in a first-party cookie fetch('/api/store-attribution', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grclid }), }); ``` ### Step 2: Pass the click identifier through your deep link Use your deep-link or deferred-deep-link setup to carry `grclid` into the app. A plain App Store link does not preserve website parameters automatically. ### Step 3: Report the install On first open, create an install ID once and persist it with the original first-open time. Reuse both on retries. Your app sends these values and any recovered `grclid` to your authenticated backend; the backend validates and stores the install before sending to Gravity. Keep your Gravity API key in the backend's `GRAVITY_API_KEY` environment variable. Never put it in the app binary. Website-only Pixel setup does not track native app events. ```python theme={null} import requests import hashlib import os API_KEY = os.environ["GRAVITY_API_KEY"] GATEWAY_URL = "https://conversions.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() ``` ```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://conversions.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 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 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 call **your backend**, using your app user's session token. Implement that route to authenticate the user, validate consent and the saved install, and call the Python or Node.js sender above. Run the Kotlin request off the UI thread. `first_opened_at` is Unix seconds, not milliseconds. ### Post-install events Track in-app purchases and other downstream events with the same pattern: ```python theme={null} payload = { "data": [{ "event_name": "InAppPurchase", "event_time": 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 fire | | - | - | - | | `AppInstall` | `app_install` | First app open after install | | `AppOpen` | `app_open` | Subsequent app opens (re-engagement) | | `InAppPurchase` | `in_app_purchase` | In-app purchase completed | | Any custom string | Lowercased | Custom app events | ### View-through attribution (no click) If the user installs without clicking an ad (e.g., they saw the ad but went to the App Store directly), send the event without a `grclid`. Include the user's email hash and Gravity will attempt to attribute the install to a recent ad impression. ```python theme={null} payload = { "data": [{ "event_name": "AppInstall", "event_time": 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 MMPs other than Adjust, configure a postback URL in the MMP's dashboard. On Adjust the callbacks go on the tracker link instead, see [Option 1](#option-1-adjust). Keep API keys in the MMP's server postback configuration, never in your app binary or public tracking links. ### Supported MMPs | MMP | Postback URL | | - | - | | **AppsFlyer** | `https://conversions.trygravity.com/mmp/postback/appsflyer` | | **Adjust** | `https://conversions.trygravity.com/mmp/postback/adjust` | | **Kochava** | `https://conversions.trygravity.com/mmp/postback/kochava` | | **Branch** | `https://conversions.trygravity.com/mmp/postback/branch` | | **Singular** | `https://conversions.trygravity.com/mmp/postback/singular` | | **Other** | `https://conversions.trygravity.com/mmp/postback/generic` | ### Setup In your MMP's partner configuration for Gravity, ensure that the click identifier from Gravity's ad URL is captured by the MMP as a custom parameter. Most MMPs call this `clickid`, `click_id`, or `label`. In your MMP dashboard, add Gravity as a postback partner. Use the URL template for your MMP (see examples below). Send a test postback to verify: ```bash theme={null} curl --get "https://conversions.trygravity.com/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" ``` This records a conversion; MMP postbacks have no validation-only test mode. Confirm receipt in Events Manager. A made-up click ID cannot verify ad attribution. ### Postback URL templates ``` https://conversions.trygravity.com/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 ``` ``` https://conversions.trygravity.com/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 ``` ``` https://conversions.trygravity.com/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 ``` ``` https://conversions.trygravity.com/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 mapping The postback receiver normalizes common MMP event names: | MMP Event | Gravity Event Type | | - | - | | `install`, `attributed_install`, `first_open` | `app_install` | | `session`, `re_engagement`, `app_open` | `app_open` | | `purchase`, `in_app_purchase`, `af_purchase` | `in_app_purchase` | | `registration`, `signup` | `complete_registration` | | Custom events | Lowercased, with spaces and hyphens replaced by underscores | *** ## Response format ### Backend CAPI Backend requests to `/gateway/events` return an acknowledgement: ```json theme={null} { "events_received": 1, "events_processed": 1, "events_errored": 0, "results": [ { "event_id": "install-123", "status": "accepted" } ], "test_mode": false } ``` `accepted` means background processing was acknowledged. Confirm receipt in Events Manager; the response does not confirm storage or attribution. Use [CAPI test mode](/advertisers/server-conversions) to validate without recording a conversion. ### MMP postbacks MMP postbacks return: ```json theme={null} { "status": "ok", "conversion_id": "uuid", "attributed": true, "event_type": "app_install", "provider": "appsflyer" } ``` | Status | Meaning | | - | - | | `ok` | Processed and stored | | `duplicate` | Already seen (deduplicated) | | `error` | Processing failed | *** ## FAQ No. Gravity does not depend on IDFA for attribution. This works with iOS 14.5+ App Tracking Transparency restrictions. If the user consented to tracking and you have IDFA, you can include it for additional signal, but it's not required. Send the conversion without `grclid` but include the user's email hash (`em`). Gravity will attempt to attribute the install to a recent ad impression. Yes. Use `InAppPurchase` (or any custom event name) with `action_source: "app"`. Include `custom_data.value` for revenue attribution. Use the same `grclid` from the original install to maintain the attribution chain. On **Adjust**, send us your tracker link and event tokens and set the link we return as your ad group landing page. On another **MMP**, configure the postback URL in the MMP dashboard. **S2S** gives you full control if you'd rather call the API from your backend with all available data. All three feed into the same conversion pipeline. ## Next Track web conversions (purchases, signups, etc.). Send web conversions from your backend. # Campaigns Source: https://docs.trygravity.com/advertisers/campaigns Objectives, creative, targeting, and bidding strategies. ## Campaign objective **CPM billing.** You pay per 1,000 impressions. Optimize for reach and in-context brand recall against high-intent conversations. Optimize for a selected conversion event. ## Creative Choose a **generative** ad group for generated copy or a **static** ad group for ads you write yourself. For generative ads, you provide a seed: **title**, **CTA**, **landing URL**, **brand name**, optional **favicon** and **branding guidelines** (tone, voice, must-mention phrases). Gravity's engine generates per-conversation ad copy using this seed — each impression gets creative tuned to the specific conversation it's matching into. This means: * **You don't write 100 variations.** One set of guidelines powers unlimited generated ads. * **Copy is contextual.** If a user asks about serverless databases and your campaign is a Postgres tool, the generated ad references serverless Postgres — not generic database marketing. * **Branding stays consistent.** The seed locks down brand name, tone, and the landing URL; only the pitch adapts. ### Good creative seed ``` Brand: Neon Title: Neon — Serverless Postgres CTA: Start free URL: https://neon.tech?utm_source=gravity Guidelines: - Always mention "serverless Postgres" - Don't use the word "database" alone (too generic) - Target developers building modern apps - Highlight "scales to zero" if the context is cost-sensitive ``` ### Weak creative seed ``` Brand: Acme Corp Title: Great Products CTA: Learn More URL: https://acme.com ``` No edge, no differentiation, no guidance — the engine has nothing to work with. ## Targeting Contextual targeting — match based on what's being talked about, not user profile: | Targeting type | Example | | - | - | | **Context** | Describe relevant conversations, such as "developers choosing a serverless Postgres database" | | **Negative context** | Describe conversations to avoid | | **Geo** | Country codes (US, GB, CA) | | **Devices** | Web, iOS app, Android app | ## Bidding Choose the bid setting for your objective. Awareness campaigns use a CPM bid; Target CPA sets the target cost for a selected conversion result. Track that event before starting a conversion campaign. ## Budgets & pacing * **Daily budget** — an average daily target; spend may reach 2× on a day and at most 7× over seven days. * **Total budget** — a fixed cap for the scheduled campaign duration, where available. Gravity manages delivery against your campaign budget. Spend can vary throughout the day; delivery is not guaranteed to be evenly distributed. Budgets belong to the campaign, not individual ad groups. ## Monitoring Dashboard → **Campaigns** → pick one. You'll see: * Impressions, clicks, CTR, spend * Results and cost per result (when conversion tracking is configured) * Events Manager — received tracking events ## Audience lists Use **Audiences** to create customer lists. Campaign targeting with uploaded lists requires enabled partner-network delivery and a campaign allocated entirely to that network. Email and phone identities are hashed for matching. ## Next Measure what actually converts. # Dashboard API Source: https://docs.trygravity.com/advertisers/dashboard-api Query your advertiser dashboard data programmatically using your API key. Pull the same metrics you see in the advertiser dashboard — and create, update, and archive campaigns and ad units — from scripts, cron jobs, or custom integrations. ## Base URL ``` https://platform.trygravity.com ``` ## Authentication Every request must include your Gravity advertiser API key in the `X-API-Key` header. Grab the key from your [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" ``` This is the same API key shown on your advertiser settings page. You can regenerate it at any time. *** ## Endpoints ### GET `/advertiser-dashboard/info` Advertiser profile. Your advertiser API key. ```bash theme={null} curl https://platform.trygravity.com/advertiser-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "advertiser_id": "abc-123", "name": "Acme Corp", "email": "ads@acme.com" } ``` *** ### GET `/advertiser-dashboard/campaigns` List all campaigns with lifetime stats (impressions, clicks, spend, conversions). Filter by campaign status (e.g. `active`, `paused`, `draft`). ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/campaigns" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "campaigns": [ { "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, "impressions": 350000, "clicks": 1800, "spend": 875.50, "conversions": 42, "conversion_value": 4200.00 } ] } ``` *** ### GET `/advertiser-dashboard/campaigns/{campaign_id}` Single campaign detail with full stats and targeting config. The campaign ID. ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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": ["desktop", "mobile"], "utm_params": { "utm_source": "gravity", "utm_medium": "ai" }, "deployment_status": "deployed", "impressions": 350000, "clicks": 1800, "spend": 875.50, "conversions": 42, "conversion_value": 4200.00 } ``` *** ### GET `/advertiser-dashboard/metrics` Daily time-series performance metrics. Number of days to look back (1–365). Start date (`YYYY-MM-DD`). Overrides `days` when paired with `end_date`. End date (`YYYY-MM-DD`). Filter to a single campaign. Omit for aggregate metrics across all campaigns. IANA timezone (e.g. `America/New_York`) used to bucket days. Defaults to UTC. Set this to your ad account's reporting timezone so daily rows line up with the Gravity Dashboard and, for ChatGPT/OpenAI campaigns, with OpenAI's reporting (OpenAI ad accounts currently report in `America/New_York`). Days are bucketed by the timezone you pass in `tz`, not the timezone of the machine calling the API. A calendar day in UTC and the same day in `America/New_York` cover different hours, so the totals will differ — pick one timezone and use it consistently as your source of truth. ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/metrics?days=7&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "data": [ { "date": "2025-05-20", "impressions": 50000, "clicks": 260, "spend": 125.00, "conversions": 6, "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_revenue": 4200.00 } ``` | Metric | Description | | - | - | | `ctr` | Click-through rate (clicks / impressions × 100). | | `cpc` | Cost per click (spend / clicks). | | `cpm` | Cost per mille (spend / impressions × 1000). | | `conversion_rate` | Conversions / clicks × 100. | | `cpa` | Cost per acquisition (spend / conversions). | | `roas` | Return on ad spend (revenue / spend). | *** ### GET `/advertiser-dashboard/ad-unit-metrics` Daily time-series metrics broken down **per ad unit** — the same numbers shown under **Ad units** on a campaign's detail page. Use this for creative-level reporting and optimization, where campaign-level `/metrics` isn't granular enough. Returns one series per `(campaign, ad unit)` link, each tagged with a `data_source`. Series built from real per-ad-unit stats are `"ad_unit_stats"`. A campaign with a single linked ad unit and no per-ad-unit rows yet falls back to the campaign's own daily totals (`data_source: "campaign_fallback"`) so the numbers stay consistent with the dashboard. This endpoint reports impressions, clicks, spend, CTR, CPC, and CPM per ad unit. Conversions/CPA/ROAS are attributed at the campaign level — use `/metrics` or `/conversions` for those. Number of days to look back (1–365). Start date (`YYYY-MM-DD`). Overrides `days` when paired with `end_date`. End date (`YYYY-MM-DD`). Filter to a single campaign. Filter to a single ad unit. Returns `404` if the ad unit isn't found in your account. IANA timezone (e.g. `America/New_York`) used to bucket days. Same behavior as `/metrics` — set it to your ad account's reporting timezone. ```bash 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" ``` **Response** ```json 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 } ] } ] } ``` | Field | Description | | - | - | | `data_source` | `ad_unit_stats` for real per-ad-unit rows, or `campaign_fallback` when a single-ad-unit campaign inherits campaign-level daily totals. | | `total_impressions` / `total_clicks` / `total_spend` | Series totals over the requested range. | | `data[].ctr` | Click-through rate (clicks / impressions × 100). | | `data[].cpc` | Cost per click (spend / clicks). | | `data[].cpm` | Cost per mille (spend / impressions × 1000). | *** ### GET `/advertiser-dashboard/ad-group-metrics` Daily time-series metrics broken down **per ad group** — the current campaign → ad groups → ads model. Use this for ad-group reporting (e.g. a weekly performance CSV) where campaign-level `/metrics` isn't granular enough. Returns one series per ad group, summed across publishers. Ad groups with no delivery in the requested range are still listed, with zero totals and an empty `data` array. A day that has conversions but no delivery is still returned, with zero impressions. A single call returns at most 2,000 ad groups; the response always reports `total_ad_groups` and `truncated` so you can tell whether you got a complete export, and `offset` pages through the rest. Conversions are counted the same way campaign `/metrics` counts them (deduplicated, same attribution window — currently a conversion counts when it happens within 7 days of a Gravity click or within 7 days of a Gravity view; this setting can change), split by the ad group that served the click — so a campaign's ad groups never total more conversions than the campaign itself. Conversions from clicks served before ad-group attribution shipped have no ad group and appear only in campaign `/metrics`, as do view-through conversions, which are attributed to a campaign without a click. Both mean a campaign's ad-group conversions and Results can total less than the campaign's — reconcile against `/metrics` for the complete count. Results count the campaign's selected conversion event, the same way Ads Manager counts them. Number of days to look back (1–365). Start date (`YYYY-MM-DD`). Overrides `days` when paired with `end_date`. End date (`YYYY-MM-DD`). Filter to a single campaign. Returns `404` if the campaign isn't in your account. Filter to a single ad group. Returns `404` if the ad group isn't found in your account. Include archived ad groups. Skip this many ad groups. Keep requesting with `offset += 2000` while `truncated` is `true` to export an account with more than 2,000 ad groups. IANA timezone (e.g. `America/New_York`) used to bucket days. Same behavior as `/metrics` — set it to your ad account's reporting timezone. ```bash 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" ``` **Response** ```json 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 } ``` | Field | Description | | - | - | | `total_ad_groups` | How many ad groups match your filters in total, ignoring the 2,000-per-call cap. | | `truncated` | `true` when more ad groups matched than were returned — re-request with a higher `offset`. | | `group_type` | `generative` or `manual`. | | `total_impressions` / `total_clicks` / `total_spend` / `total_conversions` / `total_results` / `total_revenue` / `total_result_revenue` | Series totals over the requested range. | | `result_type` / `result_label` | The campaign's selected result event, e.g. `purchase` / `Purchase`. `null` when the campaign objective has no Results. | | `cpa` | Cost per acquisition (spend / conversions), `null` when the group has no conversions. | | `cost_per_result` | Spend / Results, `null` when there are no Results. | | `conversion_rate` | Results / clicks × 100, `null` when there are no clicks. | | `data[].conversions` | Conversions attributed to this ad group on that day, in your `tz`. | | `data[].click_through_conversions` / `data[].view_through_conversions` | That day's conversions split by click-through and view-through. | | `data[].results` | Conversions matching the campaign's selected result event. | | `data[].revenue` / `data[].result_revenue` | Conversion value in USD for all conversions / for Results only. `null` when no conversion reported a USD value. | | `data[].ctr` | Click-through rate (clicks / impressions × 100). | | `data[].cpc` | Cost per click (spend / clicks). | | `data[].cpm` | Cost per mille (spend / impressions × 1000). | | `data[].cpa` / `data[].cost_per_result` / `data[].conversion_rate` | Same as the totals above, for that day. | *** ### GET `/advertiser-dashboard/conversions` Recent conversions with pagination. Returns attributed and unattributed conversions. Page size (1–100). Opaque pagination cursor (pass `next_cursor` from a previous response). Filter to a single campaign. Filter by conversion event type (e.g. `purchase`, `lead`). ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/conversions?limit=10" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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 } ], "total_conversions": 42, "has_more": true, "next_cursor": "conv-788" } ``` *** ### GET `/advertiser-dashboard/billing` Billing summary — total spend, billed, unbilled, credits. ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/billing" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "total_spend_dollars": 875.50, "total_billed_dollars": 800.00, "unbilled_dollars": 75.50, "credits_dollars": 0.00, "last_spend_sync_at": "2025-05-20T15:00:00" } ``` *** ### GET `/advertiser-dashboard/events/analytics` Pixel event analytics — domain breakdowns, time-series, and event type counts from your tracking pixel. Number of days to look back (1–365). Start date (`YYYY-MM-DD`). Overrides `days` when paired with `end_date`. End date (`YYYY-MM-DD`). Filter by domain (partial match). Filter by event type (e.g. `page_view`, `form_submit`, `click`). ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/events/analytics?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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` Conversion time-series — daily totals and per-event-type breakdowns. Number of days to look back (1–365). Start date (`YYYY-MM-DD`). Overrides `days` when paired with `end_date`. End date (`YYYY-MM-DD`). Filter by conversion event type (e.g. `purchase`, `lead`). ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/events/conversion-stats?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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` Connected Shopify stores. See [Integrations](/advertisers/integrations) for how to connect one. ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/integrations" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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" } ] } ``` *** ## Managing campaigns These endpoints let you create, edit, pause, and archive advertiser campaigns from the same API key surface. ### POST `/advertiser-dashboard/campaigns` Create a campaign with inline ad groups (recommended) or legacy ad-unit links. Campaign name. Destination URL for the campaign. Average daily budget in dollars. Required except for `target_cpa` or total-budget campaigns. Spend may reach 2× on a day and at most 7× over seven days. `daily` or `total`. Total budgets require `total_budget`, `end_time` and enabled OpenAI-only delivery. Fixed spend cap for a total-budget campaign's scheduled duration. Omit `daily_budget` in this mode. Optional calendar-month spend cap for `target_cpa` campaigns. Campaign status. Use `draft`, `active`, or `paused`. Campaign objective. `cpm` (impressions) or `cpc` (clicks). Fixed after the campaign launches on OpenAI. Bid strategy. Whether the campaign is active. Optional start timestamp. Optional end timestamp. Optional click goal. Legacy campaign-wide fallback bid for click-billed ad groups without their own `max_cpc`. Set per-group bids instead; this field does not choose the billing model. `0` is treated as unset. Optional impression goal. Optional max CPM. Optional conversion type. Optional max CPA. Optional demographics configuration. Optional UTM parameters as key/value pairs. Optional country targeting. Optional device targeting: `web`, `ios_app`, `android_app`. `dynamic`, `gravity` or `openai`. OpenAI-only delivery requires account access; omit to use your account's default. Ad groups to create inline with the campaign. Each entry uses the same shape as [`POST /advertiser-dashboard/ad-groups`](#post-/advertiser-dashboard/ad-groups) minus `campaign_id` (it is set from the new campaign). Manual groups may include authored `ads`; generative groups generate their own ads and reject authored `ads`. Legacy: one or more existing ad-unit IDs to link. Still supported, but ad groups are the current model. Provide at least one of `ad_groups` or `ad_unit_ids` — the router returns `422` if both are missing or empty. Use `network_mode` for network selection. `keywords`, `network_type` and `network_config` are not accepted here. ```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": "active", "campaign_objective": "conversions", "billing_model": "cpm", "bid_strategy": "lowest_cost", "allowed_countries": ["US", "CA"], "allowed_devices": ["desktop", "mobile"], "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" } ] } ] }' ``` **Response** ```json theme={null} { "campaign_id": "camp-456", "user_id": "adv-123", "name": "Summer Sale", "landing_page": "https://acme.com/summer", "daily_budget": 100, "status": "active", "campaign_objective": "conversions", "is_active": true, "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": ["desktop", "mobile"], "utm_params": null, "custom_favicon_url": null, "deployment_status": "pending_review", "review_status": "pending_review", "submitted_for_review_at": "2025-05-01T00:00:00", "reviewed_at": null, "review_notes": null, "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 } ] } ``` Ad-group response objects also include targeting and budget fields (`context`, `landing_page`, `daily_budget`, `max_cpc`, `allowed_countries`, `allowed_devices`, etc.) — see [Managing ad groups & ads](#managing-ad-groups-&-ads). ### PATCH `/advertiser-dashboard/campaigns/{campaign_id}` Edit a campaign or pause/unpause it. The campaign ID. Optional campaign name. Optional landing page URL. Optional average daily budget. Send `null` to clear it only on a `target_cpa` campaign. Optional campaign objective. Legacy campaign-wide fallback bid for click-billed ad groups without their own `max_cpc`. Send `null` (or `0`) to clear it; this does not change the billing model. Optional status. Use `active`, `paused`, or `draft`. Optional start timestamp. Optional end timestamp. Optional country targeting. Optional device targeting. Optional UTM parameters as key/value pairs. Optional favicon URL. ```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": "paused" }' ``` **Response** ```json 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": ["desktop", "mobile"], "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 } ``` Activating a campaign runs the billing gate and can return `402`. Budget or network changes can force the Gravity-only fallback, and activation can resubmit the campaign for review and enqueue background sync. ### PATCH `/advertiser-dashboard/campaigns/{campaign_id}/archive` Toggle a campaign's archived state. The campaign ID. ```bash theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/campaigns/camp-456/archive \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "campaign_id": "camp-456", "is_archived": true } ``` Archiving also sets `is_active = false` and `status = "paused"`. ## Managing ad groups & ads Campaigns are now structured as **campaign → ad groups → ads**. An ad group is either: * **`generative`** (default) — Gravity generates ads for the group from your `context` / `ad_prompt`. Authored `ads` are rejected with `422`. * **`manual`** — you author the ads yourself via the `ads` array or the ads endpoints below. Generated ads are read-only; only manual ads can be edited or archived. `brand_name` on an ad group is consumer-facing: it is the brand shown alongside the ad, and becomes the ad's title when the ad has no `headline`. If you provision groups programmatically on behalf of your own users, map the *end advertiser's* brand into it — reusing your internal account or workspace label (e.g. `"Jane Doe on YourApp"`) will serve that string to readers. Patching `brand_name` does not rewrite headlines already generated for the group; recreate the group or set an explicit `headline` on a manual ad to change what is served. These endpoints share the same implementation and ownership checks as the Gravity dashboard, scoped to your API key's account. ### GET `/advertiser-dashboard/ad-groups` List your ad groups, each with its ads embedded (up to 100 per group; `ads_total` carries the full count). Filter to a single campaign. Include archived groups. ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/ad-groups?campaign_id=camp-456" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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 under one of your campaigns. The campaign to attach the group to. Must be owned by your account. Ad-group name. `generative` or `manual`. `active`, `paused`, or `archived`. Targeting context used to match conversations (and to generate ads for generative groups). Conversations to avoid. Optional prompt guiding ad generation (generative groups). Default landing page for the group's ads. The advertised brand, shown to end users. Gravity uses it as the ad's title when an ad has no `headline` of its own, so send the product or company being advertised (`"Acme"`), never an internal account, workspace, or person label (`"Jane Doe on YourApp"`). If omitted, Gravity falls back to the `landing_page` hostname. Legacy stored field. Set delivery budgets on the campaign. Optional group-level max CPC. Optional country targeting override. Optional device targeting override. Authored ads (manual groups only). Each ad supports `name`, `headline` (≤80 chars), `copy` (≤500 chars), `cta` (≤80 chars), `description`, `landing_page`, `image_url`, and `status`. ```bash 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" } ] }' ``` Returns `201` with the created group and its ads (same shape as the list response). `422` if authored `ads` are sent to a generative group. ### GET `/advertiser-dashboard/ad-groups/{ad_group_id}` Fetch a single ad group with its ads. ### PATCH `/advertiser-dashboard/ad-groups/{ad_group_id}` Update any of the fields above (send only what changes). Converting a `manual` group to `generative` returns `422` while it still has live manual ads. ```bash 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). Returns `204`. ### POST `/advertiser-dashboard/ad-groups/{ad_group_id}/ads` Author an ad in a **manual** group. Returns `201` with the created ad; `422` on generative groups. ```bash 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" }' ``` **Response** ```json 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" } ``` ### PATCH `/advertiser-dashboard/ads/{ad_id}` Edit a manual ad (`headline`, `copy`, `cta`, `landing_page`, `status`, etc.). Generated ads are read-only and return `422`. ### DELETE `/advertiser-dashboard/ads/{ad_id}` Archive a manual ad. Returns `204`. *** ## Legacy: ad units (deprecated) Ad units are the legacy creative model. They keep working for existing integrations — nothing breaks — but new integrations should use [ad groups & ads](#managing-ad-groups-&-ads) instead. Campaigns are now structured as campaign → ad group → ad. ### POST `/advertiser-dashboard/ad-units` Create an ad unit owned by your advertiser account. Ad-unit name. Optional headline. Required at submit time. Required at submit time. Required at submit time. Required at submit time. Optional favicon URL. Optional image URL. The advertised brand, shown to end users — the product or company being advertised, not an internal account, workspace, or person label. Used as the ad's title when no `headline` is set. Optional UTM parameters as key/value pairs. `ad_text_mode` is not accepted here. The router also rejects `source`, `status`, `review_status`, `review_reason`, `metadata`, and `generate_static_text` because those fields are server-managed. ```bash 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" }' ``` **Response** ```json 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` Generate draft ad units from a landing page URL. The campaign ID. Landing page URL to scrape. Number of draft ad units to generate, from 1 to 5. ```bash 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 }' ``` **Response** ```json 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 without replacing the existing set. The campaign ID. One or more ad-unit IDs. Duplicate IDs are deduped before write. ```bash 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"] }' ``` **Response** ```json theme={null} { "campaign_id": "camp-456", "ad_unit_ids": ["ad-123", "ad-456"] } ``` The router marks linked units for review when needed, resubmits already-approved campaigns that now contain unreviewed ad units, and schedules the campaign ad-unit sync task in the background. ### PUT `/advertiser-dashboard/campaigns/{campaign_id}/ad-units` Replace the campaign's ad-unit set. The campaign ID. Replacement ad-unit IDs. Duplicate IDs are deduped before write. ```bash 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"] }' ``` **Response** ```json theme={null} { "campaign_id": "camp-456", "ad_unit_ids": ["ad-456", "ad-789"] } ``` This route has the same ownership checks and review/sync behavior as the `POST` variant, but it replaces the existing linked set before returning. ### PATCH `/advertiser-dashboard/ad-units/{ad_unit_id}/archive` Toggle an ad unit's archived state. The ad unit ID. ```bash theme={null} curl -X PATCH https://platform.trygravity.com/advertiser-dashboard/ad-units/ad-123/archive \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "ad_unit_id": "ad-123", "status": "archived" } ``` Unarchiving restores `status = "draft"`. *** ## Managing pixels ### POST `/advertiser-dashboard/pixels` Create a conversion pixel for a site. Verification of the pixel installation runs in the background after creation. The site URL the pixel will be installed on. `https://` is prepended if no scheme is given. Display name for the pixel. Defaults to `Untitled 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" }' ``` **Response** (`201`) ```json 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" } ``` `is_functional` starts as `null` and flips to `true`/`false` once background verification confirms whether the pixel script is installed on the URL. Returns `409` if a pixel already exists for the URL, and `422` if the URL is invalid. ### GET `/advertiser-dashboard/pixels` List all pixels on your account. ```bash theme={null} curl https://platform.trygravity.com/advertiser-dashboard/pixels \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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" } ] ``` *** ## MCP server The reporting endpoints above are also exposed as an [MCP](https://modelcontextprotocol.io) server, so AI agents and MCP-capable clients can query your account directly. * **Endpoint:** `POST https://platform.trygravity.com/mcp` (JSON-RPC 2.0 over HTTP) * **Auth:** `X-API-Key` header (or `Authorization: Bearer `) An advertiser API key unlocks read-only tools mirroring this API: `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`. ```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"}' ``` *** ## Code examples ```python Python theme={null} import requests API_KEY = "your-api-key" BASE = "https://platform.trygravity.com/advertiser-dashboard" # Get last 30 days of metrics metrics = requests.get( f"{BASE}/metrics", headers={"X-API-Key": API_KEY}, params={"days": 30}, ).json() print(f"Impressions: {metrics['total_impressions']:,}") print(f"Spend: ${metrics['total_spend']:.2f}") print(f"Conversions: {metrics['total_conversions']}") print(f"ROAS: {metrics['total_revenue'] / metrics['total_spend']:.2f}x" if metrics['total_spend'] > 0 else "") ``` ```javascript JavaScript theme={null} const API_KEY = "your-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}`); ``` ```bash curl theme={null} # Get advertiser info curl https://platform.trygravity.com/advertiser-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" # Get metrics for a specific date range curl "https://platform.trygravity.com/advertiser-dashboard/metrics?start_date=2025-05-01&end_date=2025-05-31" \ -H "X-API-Key: YOUR_API_KEY" # List active campaigns curl "https://platform.trygravity.com/advertiser-dashboard/campaigns?status=active" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Errors | Status | Description | | - | - | | `401` | Invalid or missing `X-API-Key`. | | `400` | Invalid parameter (e.g. a malformed metrics date). | | `402` | Campaign activation is blocked by billing. | | `404` | Campaign not found or doesn't belong to your account. | | `422` | Validation errors, including out-of-range `days`, missing/empty `ad_unit_ids`, removed fields like `keywords` and `ad_text_mode`, missing required ad-unit fields, invalid favicon URLs, or no update fields on `PATCH /campaigns/{campaign_id}`. | ## Questions Email [support@trygravity.com](mailto:support@trygravity.com) for anything API-related. We read it. # Integrations Source: https://docs.trygravity.com/advertisers/integrations Track purchases and bookings with Shopify, Stripe, or Cal.com. If your conversions already live in Shopify, Stripe, or Cal.com, you don't need to build a [Conversions API](/advertisers/server-conversions) integration. Connect the platform once and Gravity ingests the events for you. For Stripe and Cal.com, pass the browser's attribution context to the provider when creating the checkout or booking. Integrations replace the *conversion* half of the setup, not the pixel. You still need the [Gravity pixel](/advertisers/pixel) on your site so we can attribute the visit that led to the purchase — except for Shopify, which installs its own pixel for you. ## Shopify One-click. Gravity installs a Shopify web pixel on your storefront and subscribes to your `orders/paid` webhook, so both checkout-time and server-side purchase events flow in. Go to **Settings → Organization → Integrations** in the [Gravity dashboard](https://app.trygravity.com) and hit **Connect** on the Shopify row. Follow the connection dialog to install the Gravity app in your Shopify store and approve the requested permissions. Gravity activates the web pixel and starts syncing your catalog automatically. The row turns **verified** once we've seen a valid event from the store. Purchases arrive as `purchase` conversions with order value and currency. The checkout pixel and the `orders/paid` webhook are deduplicated against each other on the Shopify order ID, so a single order uses the same deduplication identity across both senders. Because Shopify installs its own pixel, you don't need the manual `gr-pix.js` snippet on your storefront. Add it to any *non*-Shopify pages (marketing site, docs, blog) that visitors land on first. ## Stripe Two ways to connect, depending on whether you want a one-click flow or full control. **Settings → Organization → Integrations → Connect** on the Stripe row. You authorize Gravity through Stripe's OAuth flow and we handle the webhook plumbing on our side. Create a webhook endpoint in your own Stripe dashboard pointing at: ``` https://api.trygravity.com/stripe-conversions/webhook?api_key=YOUR_API_KEY ``` Subscribe it to `checkout.session.completed` and `invoice.paid`, then paste the endpoint's **signing secret** into Gravity under **Settings → Organization → Integrations**. We verify the `Stripe-Signature` header on every event with that secret. | Stripe event | Recorded as | | - | - | | `checkout.session.completed` | `purchase` — one-time payments | | `invoice.paid` | `purchase` — first invoice of a subscription | Subscription **renewals** are deliberately not counted as new conversions — only the first `invoice.paid` for a subscription is. `payment_intent.succeeded` is also ignored: it fires underneath the events above and would double-count. Pass `getCAPIData()` click fields (`grclid`, `grcpid`, `grpuid`, `gradid`, `grsig`, `graid`) into the Checkout Session's `metadata`, or use `client_reference_id` for `grclid`. Authorize Stripe only if Stripe processes the conversions you want to track. ## Cal.com For demo bookings and consulting flows. Cal.com posts booking events straight to Gravity. **Settings → Developer → Webhooks → New**, with this subscriber URL: ``` https://api.trygravity.com/caldotcom/booking?api_key=YOUR_API_KEY ``` Your advertiser API key lives in the Gravity dashboard under **Settings → Organization**. Paste that **same API key** into Cal.com's **Secret** field. Gravity verifies the `x-cal-signature-256` HMAC against it — bookings that fail verification are still recorded, but flagged as unverified. Subscribe to the booking events you care about. Each maps to its own conversion event type: | 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` | Attendee email and phone are hashed (SHA-256) on ingest — Gravity stores the hash, not the raw value. Attribution comes from the Gravity click IDs the pixel writes into your booking page URL. Pass them through to Cal.com as booking `metadata.attribution` (`grclid`, `grcpid`, `grpuid`, `gradid`) so bookings tie back to the ad that drove them. If only `grclid` makes it through, we resolve the rest server-side. ## Check what's connected The [Dashboard API](/advertisers/dashboard-api) lists your connected Shopify stores. Check Stripe and Cal.com in the dashboard's integration settings: ```bash theme={null} curl "https://platform.trygravity.com/advertiser-dashboard/integrations" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Next Not on one of these platforms? Send conversions from your own backend. Required for attribution on everything except Shopify storefronts. # Advertiser pixel Source: https://docs.trygravity.com/advertisers/pixel Track conversions back to the Gravity ad that drove them. ## Why you need this The pixel captures website activity and browser attribution context. Pair it with server-side conversions or a supported integration to connect confirmed purchases, signups, and demo requests to your ads. This unlocks: * Per-campaign conversion rate and ROAS in your dashboard * Conversion reporting tied back to specific campaigns and ads ## Install the pixel To set up tracking with your coding agent, use the [Tracking CLI](/advertisers/tracking-cli). For manual installation, follow the steps below. Add this snippet to the `` of your website. It must be on **every page** and **every subdomain** where visitors might land or convert. ```html theme={null} ``` `YOUR_PIXEL_ID` is your pixel's UUID. Find it and the ready-to-copy snippet in **Events Manager**. Use the Pixel ID, not your advertiser/account ID or API key. Add the Pixel to each site's root layout or header template. Separate subdomains need their own installation. ### Verify it's firing 1. Load any page on your site with the pixel installed. 2. Open your browser DevTools → Network tab and look for a request to `api.trygravity.com/track/gr-events`. 3. Check for a successful response, then confirm a recent event in **Events Manager**. The **Browser pixel** row in Events Manager shows the most recent received event. A network response alone does not verify server-side conversions. ## Tracking conversions The pixel captures page views and attribution context. Conversions (purchases, signups, etc.) are tracked separately — see the [CAPI guide](/advertisers/server-conversions) for setup. ## Optional — Improve attribution with a first-party proxy The script is served from `code.trygravity.com`; the current bundle sends default event, session and configuration requests to `api.trygravity.com`. Existing `.ai` script URLs remain compatible. A first-party proxy routes Pixel requests through **your own domain**. It can help when tracking requests to third-party domains are blocked. ### Add a forwarding rule Pick your platform: Add 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 as usual — Vercel handles everything at the edge. Create a Worker that intercepts `/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); }, }; ``` Attach the Worker to your domain's route via the Cloudflare dashboard. Add 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; } ``` ```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} } } ``` Enable `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" ``` Using `http-proxy-middleware`: ```javascript theme={null} const { createProxyMiddleware } = require("http-proxy-middleware"); app.use( "/gr", createProxyMiddleware({ target: "https://api.trygravity.com/proxy", changeOrigin: true, }) ); ``` You can use any path you like instead of `/gr/`. ### Configure the pixel Add the proxy endpoint to your pixel install snippet, before the main pixel script tag: ```html theme={null} ``` Replace `/gr/` with whatever path you chose in your forwarding rule. ### Verify it's working 1. Load a page on your site with the pixel installed 2. Open DevTools → Network tab 3. Look for requests to **your domain** at `/gr/init`, `/gr/events` and `/gr/session` Check for successful responses and confirm a recent Browser pixel event in Events Manager. ## Respect consent Connect the Pixel and backend sender to your existing consent rules. Where consent is required, load the script only after consent and stop future sends if consent is withdrawn. ## Next Full CAPI setup guide with examples. Track iOS / Android installs as conversions. # Advertiser quickstart Source: https://docs.trygravity.com/advertisers/quickstart Get your ads running on AI platforms in minutes. ## What Gravity does for you Gravity places your brand inside relevant conversations on AI platforms. You can use generated ads or write your own. For a website, tracking has two parts: 1. **Install the Gravity pixel** on your website so we can track who clicked your ad 2. **Send us your conversions** so we can tell you which ads are actually driving revenue That's the whole integration. Let's walk through it. *** ## Step 1 — Sign up and create a campaign 1. Go to [app.trygravity.com/advertiser/signup](https://app.trygravity.com/advertiser/signup) 2. Enter your email and company name 3. Add a credit card (you can set a billing plan later) Then create your first campaign: **Dashboard → Campaigns → New**. | Field | What to enter | | - | - | | **Name** | An internal label (users won't see this) | | **URL** | Your landing page — Gravity reads it to understand your product and generate relevant ads | | **Daily budget** | Your average daily spending target | Save your campaign, complete tracking and billing setup, then submit it for review. Saving a draft does not start delivery. *** ## Step 2 — Install the pixel on your website Use the [Tracking CLI](/advertisers/tracking-cli) with your coding agent to prepare Pixel and CAPI together, or follow the manual steps below. Choose your tracking path: | Your setup | What to install | | - | - | | Website with your own backend | Pixel + CAPI, using the CLI or the manual guides | | Shopify storefront | Gravity Shopify app for Pixel + purchases; manual Pixel on non-Shopify landing pages | | Website using Stripe or Cal.com | Pixel + the relevant conversion integration, with attribution passed to the provider | | Mobile app | Backend CAPI or MMP postbacks; website Pixel only if you also have web pages | **For websites:** install the Pixel to capture browser activity and attribution. For a mobile-only app, use the [app tracking guide](/advertisers/app-installs). Add this snippet to the `` of your website. It needs to be on **every page** and **every subdomain** where a visitor might land or convert. ```html theme={null} ``` Replace `YOUR_PIXEL_ID` with the pixel ID shown in **Events Manager**, where a ready-to-copy snippet is also available. Put the pixel in your site's root layout or header template so it loads on every page automatically. Don't add it to individual pages one by one — that's error-prone and you'll miss pages. **Using Shopify?** [Connecting Shopify](/advertisers/integrations#shopify) installs the storefront Pixel. Add the manual snippet to any non-Shopify pages where visitors land or convert. ### Verify the pixel is working 1. Open your website in a browser 2. Open DevTools (right-click → Inspect → Network tab) 3. Look for a request to `api.trygravity.com/track/gr-events` 4. Check for a successful response, then confirm a recent Browser pixel event in **Events Manager** The script loads from `.com`; its current default tracking requests use `api.trygravity.com`. With a [first-party proxy](/advertisers/pixel#add-a-forwarding-rule), requests use your own domain. *** ## Step 3 — Set up conversion tracking **CAPI is required for conversion campaigns.** Send confirmed actions from your backend, or use an integration that supplies them. Select the event your campaign should optimize for. A custom event is your own exact name for a customer action, such as `demo_booked`. Pick the option that matches your setup: One-click connect. Pixel + conversions are automatic — no code needed. Send conversions from your backend. Works with any platform. Track iOS / Android installs via your backend or MMP postback. Connect your Stripe account or point a webhook at Gravity. Booking webhook for demo / consulting flows. ### Quick CAPI example For your own backend, here's the gist. Full guide: [Server-side conversions](/advertisers/server-conversions). **1. When submitting the order**, read the pixel's attribution data and pass it to your backend: ```javascript theme={null} var capiData = window.gravityPixel?.getCAPIData?.() || {}; // Send it to your backend with the order 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 }) }); ``` **2. After your backend confirms the order**, send the conversion using trusted order details. Keep the API key on your backend and reuse the original event ID and time on retries: ```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() ``` Follow the CAPI guide to verify a received event in Events Manager. *** ## Step 4 — Watch it run **Dashboard → Campaigns** → your campaign. You'll see: * Impressions, clicks, and spend * Conversion count and ROAS (once conversions are flowing) *** ## Optional — Improve attribution accuracy For even better tracking (especially on Safari and browsers that block third-party cookies), you can route pixel requests through your own domain. It's a simple forwarding rule — no custom code. See the [first-party proxy setup](/advertisers/pixel#add-a-forwarding-rule) in the Pixel guide. *** ## Next Full CAPI setup guide with Python, Node.js, and cURL examples. Targeting, bidding, and creative guidelines. Questions? [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 Gravity for higher-fidelity attribution. **CAPI is required for conversion campaigns.** Use the pixel for browser activity and attribution context, and CAPI for backend-confirmed purchases, signups, leads, and any other event type. ## Why server-side? The [Tracking CLI](/advertisers/tracking-cli) can guide your coding agent through CAPI setup in your project. The manual integration contract is below. The Gravity pixel tracks page views and auto-detects some conversions client-side. But for the highest fidelity — especially for purchases, signups, and other backend-confirmed events — sending conversions server-to-server is more reliable: * **More reliable** — backend confirmation does not depend on browser event delivery * **Richer data** — attach customer email, phone, order details, and line items your frontend doesn't have * **Deduplication** — use `event_id` to prevent double-counting across pixel and server events * **Offline conversions** — attribute phone calls, in-store purchases, and CRM events back to ads ## How it works ```mermaid theme={null} sequenceDiagram participant Browser as User's Browser participant Pixel as Gravity Pixel participant Backend as Your Backend participant CAPI as Gravity CAPI Pixel->>Browser: Captures attribution context Browser->>Backend: Order submitted + getCAPIData() payload Backend->>CAPI: POST /gateway/events (attribution + order details) CAPI-->>Backend: Accepted for background processing ``` 1. The **Gravity pixel** runs on your site and captures attribution context automatically 2. On conversion, your **frontend** reads `window.gravityPixel.getCAPIData()` and sends it to your backend alongside the order 3. After confirming the action, your **backend** calls `POST /gateway/events` with the pixel context and trusted conversion details. Preserve the event ID and original action time across retries. ## Step 1 — Install the pixel If you haven't already, add the pixel to every page: ```html theme={null} ``` ## Step 2 — Read pixel data on conversion When the user completes a conversion (purchase, signup, etc.), call `getCAPIData()` and pass the result to your backend: ```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 }) }); ``` `getCAPIData()` is safe to call at any time after the pixel loads. If the user arrived via a Gravity ad click, the attribution fields will be populated. If not, they return `null` — the conversion still records, it just won't be attributed to a specific ad. ## Step 3 — Send the conversion from your backend Merge the pixel data with your order details and POST to the Gravity gateway. Pick your language: **Plaintext email is supported; the API normalizes and hashes it for you.** If you hash it yourself, trim whitespace and lowercase before SHA-256 hashing its UTF-8 bytes. ```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" } }] }' ``` Include `"test_event_code": "TEST123"` to validate your payload without recording a real conversion. Remove it when you go live. ## Event types | Event name | When to fire | | - | - | | `Purchase` | Order completed | | `Lead` | Form submission, demo request | | `CompleteRegistration` | Account created | | `Subscribe` | Subscription started | | `StartTrial` | Free trial started | | `InitiateCheckout` | Checkout started | | `AddToCart` | Item added to cart | | `Contact` | Contact form or phone call | | `Schedule` | Appointment booked | Custom event names are also accepted (e.g., `"event_name": "DownloadWhitepaper"`). ## User data fields ### Attribution (from pixel) These are automatically included when you use `getCAPIData()`: | Field | Description | | - | - | | `grclid` | Gravity click ID | | `grcpid` | Gravity campaign ID | | `grpuid` | Gravity publisher ID | | `gradid` | Gravity ad ID | | `grsig` | Gravity attribution (UTM) signature | | `graid` | Gravity attribution ID — resolves to the other IDs server-side | | `visitor_id` | Visitor ID | | `session_id` | Current session ID | | `client_user_agent` | Browser user agent string | Pass the entire `user_data` object through unchanged from `getCAPIData()` whenever possible. ### Device context (from pixel) The `client_context` object is automatically included by `getCAPIData()`. It contains device and environment signals that improve conversion quality: | Field | Description | | - | - | | `timezone` | User's timezone (e.g. `America/New_York`) | | `screen` | Screen dimensions and color depth | | `viewport` | Browser viewport dimensions | | `platform` | OS platform (e.g. `macOS`, `Windows`) | | `connection` | Network connection type | Pass the entire `client_context` object through from your frontend to your backend, then include it in the CAPI request — no manipulation is needed on your end. Gravity uses these signals to improve attribution accuracy. ### Customer identity (from your backend) Add these server-side for richer matching: | Field | Type | Description | | - | - | - | | `em` | `string[]` | Email address(es) — plaintext OK, auto-hashed before storage | | `ph` | `string[]` | Phone number(s) — **send this whenever you collect one.** Plaintext OK (auto-hashed); the API normalizes to digits before hashing, so `+1 (555) 123-4567`, `15551234567`, and E.164 all match | | `fn` | `string` | First name | | `ln` | `string` | Last name | | `external_id` | `string[]` | Your system's customer/user IDs | PII fields (`em`, `ph`, `fn`, `ln`) can be sent as plaintext; Gravity normalizes and SHA-256 hashes them for conversion storage. Pre-hashed values must be 64-character hex strings. Normalize emails by trimming and lowercasing, and phones to digits only, before hashing. Include a phone number (`ph`) when you collect it and your consent rules allow sharing it. Gravity can use the hash as an additional identity signal. ## Custom data | Field | Type | Description | | - | - | - | | `value` | `float` | Conversion value (e.g., order total) | | `currency` | `string` | ISO 4217 currency code (default: `USD`) | | `order_id` | `string` | Your order/transaction ID | | `contents` | `object[]` | Line items: `{ id, quantity, item_price, title }` | | `content_name` | `string` | Product or content name | | `content_category` | `string` | Product category | | `predicted_ltv` | `float` | Predicted customer lifetime value | ## Response ```json theme={null} { "events_received": 1, "events_processed": 1, "events_errored": 0, "results": [ { "event_id": "order-123", "status": "accepted" } ], "test_mode": false } ``` | Status | Meaning | | - | - | | `accepted` | Accepted for background processing; not proof of storage or attribution | | `ok` | Event processed and stored | | `duplicate` | Already seen (same `event_id` + `event_name` within 48 hours) | | `test_ok` | Validated but not stored (test mode) | | `skipped` | User opted out | | `error` | Processing failed — see `error` field | Normal requests return `accepted`. Other processing statuses are returned only for synchronous requests, including test mode. ## Deduplication Events are deduplicated by `event_id` + `event_name` within a 48-hour window. Use a stable ID for each confirmed action and reuse that ID, event name and original action time on retries. If both browser and server send the same conversion, both must send the same name and ID. ## Authentication Pass your API key as a query parameter or `Authorization` header: ``` ?api_key=YOUR_API_KEY ``` or ``` Authorization: Bearer YOUR_API_KEY ``` Your API key is in **Settings → Organization** (and in **Events Manager**) in the Gravity dashboard. The API key is separate from your pixel ID. ## Batch events Send up to 1,000 events per request by adding more objects to the `data` array. The normal response acknowledges background processing. Check Events Manager for receipt; request-level validation failures can reject a batch. ## Verify it's working Three checks, in order: Send a request with `"test_event_code": "TEST123"`. A valid event returns `status: "test_ok"`. Test events are validated synchronously without storing a conversion. Remove `test_event_code` when your backend sends a confirmed conversion. A normal request returns HTTP 200 with `status: "accepted"`; storage and attribution happen in the background. Confirm a recent event on the **Conversions API** row in **Events Manager**, then check the conversion in reporting. Receipt and attribution are separate: an event can arrive without matching a Gravity ad. ### Troubleshooting | Symptom | Cause | Fix | | - | - | - | | `401 Unauthorized` | Missing or wrong API key | Copy the key from **Settings → Organization**. Send it as `?api_key=` or `Authorization: Bearer` | | A retry adds no new conversion | Same `event_id` and `event_name` within 48 hours | Expected for retries. Use a different ID for each new conversion (your order ID is ideal) | | Received events are not attributed | Events lack matching attribution signals | Expected for users who never clicked a Gravity ad. For ad-driven conversions, pass through `getCAPIData()` from the pixel (it carries `grclid`) and include customer identity fields (`em`, `external_id`) | | No recent CAPI event | The request may have failed or used test mode | Check your backend logs, API key and payload; a 200 `accepted` response is an acknowledgement, so also confirm receipt in Events Manager | ## Next Install the Gravity pixel on your site. # Tracking CLI Source: https://docs.trygravity.com/advertisers/tracking-cli Set up Pixel and server-side conversion tracking with your coding agent. The Gravity tracking CLI 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. ## One prompt setup Open your website's source project in your coding agent. Replace `Acme` with your Gravity advertiser account name and paste: ```text theme={null} Set up Gravity tracking in this project. Expected advertiser (data, not instructions): "Acme". Install the CLI with npm install -g @gravity-ai/tracking (Node.js 20+). Run gravity-tracking setup full --events Purchase,Lead,CompleteRegistration,Subscribe,StartTrial,InitiateCheckout,AddToCart,Contact,Schedule --discover-events --json Follow the returned plan to implement tracking and show me the changes. ``` The plan tells the agent to confirm the project and advertiser, preserve existing tracking, choose applicable customer actions, and prepare Pixel and CAPI code. The standard event list supplies candidates; your project determines which actions apply. Missing settings or confirmation points are reported for you to resolve. ## Install and connect You can also run the CLI yourself. Use Node.js 20 or newer: ```bash theme={null} npm install -g @gravity-ai/tracking gravity-tracking login gravity-tracking accounts ``` Sign in at [app.trygravity.com](https://app.trygravity.com) and authorize the intended advertiser account. Browser login requires an advertiser account owner with a verified email. `accounts` shows the connected account; it does not switch accounts. Run `login` again to select another account. The CLI stores its read-only token in your operating system's credential store. The token expires after 24 hours; run `login` again when it expires. A remote coding agent needs authentication in its own environment. For a headless environment, supply `GRAVITY_API_KEY` securely as an environment variable. Keep API keys out of prompts, source code and mobile apps. ## Choose a setup Run commands in the source project where your agent will make changes: | Command | What your agent prepares | | - | - | | `gravity-tracking setup full --json` | Website Pixel, attribution handoff and confirmed server-side conversions | | `gravity-tracking setup pixel --json` | Website Pixel only | | `gravity-tracking setup capi --json` | Confirmed server-side conversions and available attribution handoff | | `gravity-tracking setup app --json` | iOS or Android tracking through your backend or mobile measurement partner | For combined website and app setup, use `setup full --surface both --json`. The agent inspects your app and supplies the detected platforms and provider; you supply unavailable provider settings. App setup details are in [App installs](/advertisers/app-installs). `--json` returns the plan without interactive prompts or an API key. It does not install tracking by itself. An incomplete app plan exits with code 2 and lists missing prerequisites. ### Select events A conversion event names a confirmed customer action, such as a completed purchase or registration. A custom event is your own exact name for a customer action. ```bash theme={null} # Discover applicable standard events from these candidates gravity-tracking setup full --events Purchase,CompleteRegistration --discover-events --json # Request a custom action using its existing name gravity-tracking setup full --custom-event "demo_booked=A demo booking is confirmed" --json # Preserve a literal name containing punctuation gravity-tracking setup capi --custom-event-name "Plan=Pro" --json ``` `--events` without `--discover-events` requests the exact supplied events. Custom names are preserved in the plan and are never expanded into standard events. Use repeated `--custom-event`, `--custom-event-name` or `--goal "Business action"` options for additional actions. ### Shopify [Connect the Gravity Shopify app](/advertisers/integrations#shopify) for Shopify-managed Pixel and purchase tracking. The CLI preserves that tracking. Request custom actions separately, using `--custom-event` or `--custom-event-name` without `--discover-events`; do not add a second sender for Shopify-managed purchases. ## Review and deploy Review the agent's changes and any missing inputs. Website setup should preserve consent handling, capture browser attribution with `getCAPIData()`, and send CAPI from a confirmed backend action with a stable event ID. Add your Gravity API key to your backend's hosting environment, then deploy. Follow the [Pixel guide](/advertisers/pixel) and [CAPI guide](/advertisers/server-conversions) to verify real tracking in Events Manager. A fetched plan or successful local build does not prove events have arrived. The CLI does not deploy, provision production secrets, change provider settings or send conversion events. `--hosted-pr` and `resume` open the setup path in Events Manager; they do not run a hosted installer from the CLI. ## Command reference | Command | Purpose | | - | - | | `gravity-tracking --help` | All setup, event, app and authentication options | | `gravity-tracking login --no-browser` | Print the login URL to open on the same computer | | `gravity-tracking login --api-key` | Exchange an API key entered at a hidden terminal prompt | | `gravity-tracking status` | Read the current setup state | | `gravity-tracking logout` | Revoke CLI access and remove the saved token | The dashboard uses `.com`; the CLI currently authenticates through `https://api-interface.trygravity.ai`. Use the default API for browser login. # Changelog Source: https://docs.trygravity.com/ai-platforms/changelog What's new in the Gravity SDK and pixel — release notes for AI-platform publishers. Release notes for the JavaScript SDK (`@gravity-ai/api` / `@gravity-ai/react`) and the Gravity pixel (`gr-pix.js`). Entries are publisher-facing — they describe behavior and integration changes, not internal refactors. Latest first. Upgrading is always a drop-in replacement — `npm install @gravity-ai/api@latest` and redeploy. We don't ship breaking changes to the SDK surface on minor/patch bumps. **Ads can now include product images.** When an ad has an image, the response includes `brandImage` and the SDK shows an image card: the photo on one side, the copy on the other. Ads without an image stay as the normal text card. Gravity picks the layout server-side, so there's nothing to configure. ```bash theme={null} npm install @gravity-ai/react@latest # or @gravity-ai/ui@latest, @gravity-ai/react-native@latest ``` Using the API directly? Read `brandImage` and design your own card. To get a test image ad on demand, send `"testAd": true, "alwaysReturn": true, "testAdFormat": "image"`. Related docs: [Image ads](/engine/contextual-ads#image-ads) **Banner variant no longer truncates.** The `banner` variant now lays the ad out over two lines — brand row on top, wrapped `adText` below — instead of crushing the title and hard-truncating the copy mid-word. `1.1.8` (July 16) was the first pass at this: it ellipsized the title and stopped the label/CTA from overlapping in narrow containers. Nothing to change in your code — upgrade and the banner just renders correctly at narrow widths. ```bash theme={null} npm install @gravity-ai/react@latest ``` Related docs: [Show ads](/ai-platforms/show-ads) **Server-delivered ad rendering.** New package: `@gravity-ai/ui` renders a `renderer_spec` — a declarative layout the engine sends with the ad — through a thin client runtime. Layout changes then ship from Gravity's side without you redeploying, which is what makes creative/format experiments possible on non-React and server-rendered surfaces. ```bash theme={null} npm install @gravity-ai/ui ``` Optional: `@gravity-ai/react`'s `GravityAd` stays the simplest path if you're on React. Full guide: [Server-rendered ads](/sdks/server-rendered-ads). **New placement types.** `search_result`, `top_page`, `bottom_page`, `left_page`, `right_page`, and `center_page` are now part of the `Placement` type, so TypeScript stops rejecting them when you request page-level or search surfaces. ```bash theme={null} npm install @gravity-ai/api@latest ``` Related docs: [Placements](/ai-platforms/placements) **Pixel and SDK now auto-wire end to end.** If both are loaded on the page, the SDK pulls four values off `window.gravityPixel` automatically on every `gravityContext()` call — no glue code. | Pixel method | Flows into | Why you care | | - | - | - | | `getVisitorId()` | `user.gruid` | Stable first-party visitor ID — used for attribution and higher-value auctions | | `getSessionId()` | Top-level `sessionId` | You can **drop your own `sessionId` parameter**; the pixel's 30-min rolling session ID is used unless you pass one explicitly | | `getClickId()` | `user.grclid` | Inbound Gravity click ID when a user arrives from a Gravity ad — surfaces in `engine_events.request_context.user.grclid` for attribution reporting | | `getGraid()` | `user.graid` | Inbound ad identifier, same attribution path | Caller-supplied values always win, so existing code that explicitly passes `sessionId` keeps working unchanged. **What to do**: upgrade to `1.1.7`, confirm the pixel is installed ([install guide](/ai-platforms/pixel)), optionally drop your own `sessionId` plumbing. If you're not running experiments yet, this is a good checkpoint to start — 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) **PII auto-hashing for attribution.** The SDK now hashes emails/phone numbers client-side (SHA-256, normalized) before they reach the wire, and the pixel's `gruid` is surfaced safely through the SDK so dashboards can attribute conversions without publishers having to think about privacy plumbing. No integration changes required — this shipped as internal hardening. Advertiser-side match rates go up, you don't have to do anything. **Ad composition experimentation framework.** The SDK and engine now support A/B testing different ad *compositions* (which props the server sends, which layout the client renders) on a per-request basis, controlled from the Gravity dashboard. If you want to run UI variants without shipping code — try different CTA copy, card vs. pill layouts, inline vs. below-response placements — this is the unlock. Start at [Experiments](/ai-platforms/experiments). Bucket of bug fixes and polish: * Fixed hardcoded `status: 200` in `gravityAds` success path * Fixed `setAd` hover cleanup and prop-spread order in `gravityContext` * Fixed Windows URL injection and a timeout `0` falsy-check bug * Swallowed `execFile` errors in `openUrl` to avoid crashing host apps * Deprecated the legacy `Client` + `AdParams` API in favor of `Gravity` + `gravityContext` If you're on `1.1.0`+ you've got these already. If you're still on `1.0.x`, upgrade when convenient — none of these are security-critical but the DX is materially better. Pre-`1.1.x` history is available as version tarballs 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 short version: `0.0.x → 0.1.x → 1.0.0` covered the initial public API, ad card + impression logic, the publisher playground, and the first round of `@gravity-ai/react` templates. If you're still pinned anywhere in this range, upgrade straight to `latest` — the API surface is stable across the `1.x` line. ## How versions ship * **`@gravity-ai/api`** and **`@gravity-ai/react`** are versioned independently. Most publishers only need `@gravity-ai/api`; `@gravity-ai/react` is a thin wrapper with pre-built components. * **`latest`** on npm always reflects the newest stable release. We do not ship breaking changes to published APIs on `1.x` minor/patch bumps. * The **pixel** (`gr-pix.js`) is served from `https://code.trygravity.ai/gr-pix.js` and updates in place — you don't pin a version, you always get the current build. Changes are backwards-compatible. ## Questions or issues? * **Integration help**: see [Integration guide](/ai-platforms/integration-guide) or email [support@trygravity.com](mailto:support@trygravity.com). * **Experiment setup** — see [Experiments](/ai-platforms/experiments). * **Going live checklist** — see [Going live](/ai-platforms/going-live). # Dashboard API Source: https://docs.trygravity.com/ai-platforms/dashboard-api Query your dashboard data programmatically using your API key. Pull the same metrics you see in the dashboard (revenue, impressions, placements, devices, geography) from scripts, cron jobs, or custom integrations. Reporting endpoints are read-only; `POST /publisher-dashboard/placements` lets you create placements programmatically. ## Base URL ``` https://platform.trygravity.com ``` ## Authentication Every request must include your Gravity API key in the `X-API-Key` header. Grab the key from your [dashboard](https://app.trygravity.com) under **Settings → Platform Settings**. ```bash theme={null} curl https://platform.trygravity.com/publisher-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` This is the same API key used to serve ads. No extra credentials required. *** ## Endpoints ### GET `/publisher-dashboard/info` Publisher profile and lifetime totals. Your Gravity API key. ```bash theme={null} curl https://platform.trygravity.com/publisher-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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` Daily performance time series — impressions, clicks, revenue, CPM, CPC, CTR. Number of days to look back (1–3650). Start date (`YYYY-MM-DD`). Overrides `days` when paired with `end_date`. End date (`YYYY-MM-DD`). IANA timezone for date bucketing (e.g. `America/New_York`). ```bash theme={null} curl "https://platform.trygravity.com/publisher-dashboard/stats?days=7&tz=America/New_York" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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` Ad request funnel — requests, wins, impressions, fill rate, show rate. Number of days (1–30). Start date (`YYYY-MM-DD`). End date (`YYYY-MM-DD`). IANA timezone. ```bash theme={null} curl "https://platform.trygravity.com/publisher-dashboard/activity?days=7" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "data": [ { "date": "2025-05-20", "requests": 50000, "wins": 35000, "impressions": 30000 } ], "total_requests": 350000, "total_wins": 245000, "total_impressions": 210000, "fill_rate": 0.70, "show_rate": 0.8571 } ``` | Field | Description | | - | - | | `fill_rate` | `wins / requests` — how often a campaign matched. | | `show_rate` | `impressions / wins` — how often matched ads were viewed. | *** ### GET `/publisher-dashboard/placements` Per-placement performance breakdown with daily time series. Number of days (1–365). Start date (`YYYY-MM-DD`). End date (`YYYY-MM-DD`). ```bash theme={null} curl "https://platform.trygravity.com/publisher-dashboard/placements?days=7" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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` Create a new placement (ad slot) without using the dashboard. The returned `slug` is the `placement_id` to send on ad requests. The placement is created `active`. A new placement can take up to 15 minutes to become servable while the placement cache refreshes; ad requests sent before then get no ad for that slot. Display name (max 100 characters). The slug is derived from it (`"Sidebar Slot"` → `Sidebar-Slot`) and must be unique per publisher. One of `above_response`, `below_response`, `inline_response`, `left_response`, `right_response`, `search_result`, `center_page`, `top_page`, `bottom_page`, `left_page`, `right_page`. One of `all`, `desktop`, `mobile`, `tablet`. One of `any`, `react`, `nextjs`, `vite`, `react-native`, `swift`, `vanilla`, `other`. URL of the page or screen where the slot lives. Free-form notes. Optional `{ "max_ad_text_chars": integer }`: caps ad body length for this slot. Minimum 120. ```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" }' ``` **Response** (`201 Created`) ```json 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, "created_at": "2026-09-10T20:45:00+00:00", "updated_at": "2026-09-10T20:45:00+00:00" } ``` | Status | Reason | | - | - | | `409` | A placement with the same slug already exists for your publisher. | | `422` | `name` is empty, has no alphanumeric characters, or exceeds 100 characters; an enum value is invalid; or `max_ad_text_chars` is below 120. | *** ### GET `/publisher-dashboard/devices` Performance breakdown by device type (desktop, mobile, tablet). Number of days (1–365). Start date (`YYYY-MM-DD`). End date (`YYYY-MM-DD`). ```bash theme={null} curl "https://platform.trygravity.com/publisher-dashboard/devices?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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` Per-country performance breakdown. Number of days (1–365). Start date (`YYYY-MM-DD`). End date (`YYYY-MM-DD`). ```bash theme={null} curl "https://platform.trygravity.com/publisher-dashboard/geography?days=30" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json 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` Top countries by user count. Number of countries to return (1–100). ```bash theme={null} curl "https://platform.trygravity.com/publisher-dashboard/countries?limit=10" \ -H "X-API-Key: YOUR_API_KEY" ``` **Response** ```json theme={null} { "countries": [ { "country_code": "US", "requests": 200000, "ads_served": 140000, "unique_users": 8000, "pct_requests": 57.1 } ] } ``` *** ## MCP server The reporting endpoints above are also exposed as an [MCP](https://modelcontextprotocol.io) server, so AI agents and MCP-capable clients can query your account directly. * **Endpoint:** `POST https://platform.trygravity.com/mcp` (JSON-RPC 2.0 over HTTP) * **Auth:** `X-API-Key` header (or `Authorization: Bearer `) A publisher API key unlocks read-only tools mirroring this API: `publisher_get_info`, `publisher_get_stats`, `publisher_get_activity`, `publisher_get_placements`, `publisher_get_devices`, `publisher_get_geography`, and `publisher_get_countries`. ```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"}' ``` *** ## Code examples ```python Python theme={null} import requests API_KEY = "your-api-key" BASE = "https://platform.trygravity.com/publisher-dashboard" # Get last 30 days of performance stats 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 = "your-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)}`); ``` ```bash curl theme={null} # Get publisher info curl https://platform.trygravity.com/publisher-dashboard/info \ -H "X-API-Key: YOUR_API_KEY" # Get stats for a specific date range curl "https://platform.trygravity.com/publisher-dashboard/stats?start_date=2025-05-01&end_date=2025-05-31" \ -H "X-API-Key: YOUR_API_KEY" # Get placement breakdown curl "https://platform.trygravity.com/publisher-dashboard/placements?days=7" \ -H "X-API-Key: YOUR_API_KEY" ``` ## Errors | Status | Description | | - | - | | `401` | Invalid or missing `X-API-Key`. | | `400` | Invalid parameter (e.g. bad timezone, out-of-range `days`). | | `409` | Duplicate placement slug. | | `422` | Missing required parameter or invalid body field. | ## Questions Email [support@trygravity.com](mailto:support@trygravity.com) for anything API-related. We read it. # Experiments Source: https://docs.trygravity.com/ai-platforms/experiments Gravity tests ad designs without publisher code changes. Gravity can test different ad designs on your traffic and measure which performs best. Experiments are configured and monitored by the Gravity team. ## Integration No experiment-specific code is required. Render the returned ad with the standard SDK component: ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; ``` Gravity assigns the experience server-side, keeps it consistent for the session, and preserves normal tracking and attribution. Contact [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 run before flipping to production. ## Flip the switch ```ts Node / JS / TS theme={null} const gravity = new Gravity({ production: true }); ``` ```python Python theme={null} gravity = Gravity(production=True) ``` That's the only code change. Once `production: true` is set, the engine returns real ads matched to real campaigns, impressions and clicks count toward your revenue, and everything flows through to your dashboard. ## Pre-flight checklist Before flipping, verify each of these end-to-end in test mode: `GRAVITY_API_KEY` set on your production host. The SDK picks it up automatically. `new Gravity({ apiKey: '...' })` also works if you manage secrets differently. For each `placement_id` you request, confirm the ad shows up in the right spot in the UI. Test modes return a placeholder ad so you should always see *something*. Scroll the ad into view. Network tab should show a request to `api.trygravity.ai/ack`. Using `GravityAd`? That's automatic via IntersectionObserver. Using a custom renderer? Fire `new Image().src = ad.impUrl` on visibility yourself. Click the ad. The request should go to `api.trygravity.ai/track/click` first and 302 to the advertiser's URL. Never link `ad.url` directly. Always use `ad.clickUrl`. Install the [Gravity pixel](/ai-platforms/pixel) on your site. This is required for attribution and payouts. If you have a native app with an in-app browser, also run the [in-app browser install](/ai-platforms/pixel#in-app-browser-webview) inside your WebView. Every ad request from the same conversation should carry the same `sessionId`. This powers frequency capping and experiment bucketing. Confirm each ad request body includes a `device` object with the end user's `ua` **and** `ip` — both are **required**; requests missing either are rejected with HTTP `400`. The browser SDK collects this via `gravityContext()` — make sure your backend **forwards** it to the ad request instead of dropping it. See [Device signals](/ai-platforms/request-ads#device-signals). Stop the Gravity API network. Your UI should still render. LLM stream should still work. No exceptions leak. ## After you flip `app.trygravity.com` → your AI platform account. Requests, impressions, clicks, and revenue all update in near-real-time. Fill rate = served / requested ads. New AI platforms often see lower fill rates for the first week as campaigns learn your audience. Raising the relevancy threshold or restricting placements can push fill up or down deliberately. The SDK's ad request has a 4s default timeout. If your p99 hits that, you can bump the timeout via `timeoutMs` on the constructor. Usually an ad timeout just means your LLM streams without the ad that cycle, which is the graceful-degradation behavior you want. ## Rollback Flip `production: false` on the constructor and deploy. You'll immediately go back to test ads and stop accruing revenue. Nothing else changes. ## What's next How money moves and what the dashboard tracks. Let the engine optimize your creative mix automatically. # Integration guide Source: https://docs.trygravity.com/ai-platforms/integration-guide The full AI platform lifecycle — integrate, test, pixel, ship, monitor, get paid. This is the long version of the [Quickstart](/ai-platforms/quickstart). If you just want the 5-minute happy path, start there. This guide covers everything around the edges: the full lifecycle from first line of code to recurring payouts. ## Lifecycle overview Decide where ads go in your UI, which placements you need, and whether you're using the SDK or calling the API directly. Install the SDK. Wire `gravityContext()` on the client, `gravity.getAds()` on the server, `GravityAd` (or your own renderer) in the UI. Keep `production: false`. Verify placements render, impressions fire on visibility, clicks route through `clickUrl`. Drop the [Gravity pixel](/ai-platforms/pixel) onto your site. One ` ``` Find `YOUR_PIXEL_ID` in your dashboard under **Settings → Platform Settings**. It's a UUID. ### Verify it's firing 1. Load any page with the pixel installed. 2. Open your browser DevTools → Network tab and look for a request to `api.trygravity.com/track/gr-events`. 3. A `200` response means the pixel is firing correctly. Events typically appear in your dashboard within \~10 minutes. ## In-app browser (WebView) If your app opens Gravity ad links **inside** an in-app WebView rather than the system browser, install the pixel inside the WebView too with the `inAppBrowser: true` flag. This preserves attribution when a user clicks an ad and lands on the advertiser's page inside your app. **Skip this section if** your app opens ad links in the system browser (`UIApplication.shared.open(url)` or equivalent). The web install above is all you need. ### Swift (iOS / iPadOS) ```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 ```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 ```dart theme={null} import 'package:webview_flutter/webview_flutter.dart'; final controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setNavigationDelegate(NavigationDelegate( onPageStarted: (_) async { await controller.runJavaScript(''' window.GRAVITY_PIXEL_CONFIG = { inAppBrowser: true, pixel_id: "YOUR_PIXEL_ID" }; '''); }, onPageFinished: (_) async { await controller.runJavaScript(''' !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'); '''); }, )); ``` ## Troubleshooting * Make sure the script loads last in the document (before ``). * Check DevTools Network tab for a request to `code.trygravity.com/gr-pix.js`. * Check the console for errors. If another script throws before the pixel runs, the pixel load order may need adjusting. * Confirm `YOUR_PIXEL_ID` matches the Pixel ID in **Settings → Platform Settings**. It's separate from your API key. * Check DevTools Network for `api.trygravity.com/track/gr-events` POST requests. A 200 response means we got it; dashboard updates within \~10 minutes. * iOS WebKit needs `WKWebsiteDataStore.default()` (the default). `.nonPersistentDataStore()` breaks cookie persistence. * Verify `injectionTime` / `onPageFinished` actually fires — `console.log` from inside the injected script and capture via `WKScriptMessageHandler` (Swift) or `onMessage` (RN). ## Next Final checklist before flipping `production: true`. # Placements Source: https://docs.trygravity.com/ai-platforms/placements Placements are how you tune fill, match quality, and UX — one of the biggest levers on revenue. A **placement** is a named slot in your UI where an ad can appear. Placements are a first-class concept in Gravity because they're where three things come together: 1. **Fill** — is there a campaign that wants to serve here? 2. **Match quality** — how relevant is what we serve to the conversation? 3. **UX** — how does the ad feel in your product? Those three tradeoffs are almost always the biggest lever on revenue for a given AI platform. Placements are how you operate on them. ## Why placements matter * **Per-slot economics.** The dashboard reports impressions, CTR, and revenue per `placement_id`. You'll see a `below_response` slot earn very differently from a `right_response` sidebar. That's actionable — you learn which spots in your UI carry their weight. * **Scoped experimentation.** Gravity can run [experiments](/ai-platforms/experiments) targeted to a specific placement. Test a new format in your right rail without touching your primary in-flow slot. * **Auto-optimization.** The engine learns which ad formats work best per placement over time, and leans into the winners. This only works if you've declared the slots in the first place. * **Fill-rate tuning.** Different placements tolerate different fill rates. A page-level `bottom_page` slot might want 95% fill (low relevance bar); an `inline_response` slot might want 40% fill (only serve when the match is strong). Separate placements let you set different policies. ## Standard placement types | Placement | Typical use | | - | - | | `below_response` | Under the assistant's message — most common in chat UIs. | | `above_response` | Pinned above the response. Use sparingly. | | `inline_response` | Inline within the response stream (markdown). | | `left_response` | Sidebar to the left of the response. | | `right_response` | Sidebar to the right. | | `search_result` | Within a search results list. | | `top_page` | Static page-level header. | | `bottom_page` | Static page-level footer. | | `left_page` | Persistent left rail. | | `right_page` | Persistent right rail. | | `center_page` | Centered, full-width slot. | ## Creating placements in the dashboard The dashboard under **AI platforms → Placements** lets you register the slots in your UI by `placement_id`. This is also where per-slot reporting lives and where you configure any placement-scoped experiments we run on your traffic. Create placements in the dashboard or with [`POST /publisher-dashboard/placements`](/ai-platforms/dashboard-api#post-/publisher-dashboard/placements). Sending a new `placement_id` in an ad request does **not** create it. **Register a placement before you send it.** Once your account has any placements defined, the engine filters each ad request down to your active placement identifiers (slug or UUID). An unregistered `placement_id` sent alongside registered ones is dropped from the auction — that slot gets no ad, with no error on the response. The only exception is an account with no placements defined at all, or a request where *every* `placement_id` is unrecognized; those serve unfiltered for backward compatibility. A newly created placement can take up to 15 minutes to become servable while the placement cache refreshes. ## `placement_id` — your slot name Every placement needs a `placement_id` that identifies the specific 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' }, ]); ``` Pick any stable string. `'chat-main'`, `'mobile-footer'`, `'search-result-3'` — whatever maps to the spot in your UI. Register it in the dashboard, then use it consistently; your dashboard reports revenue and CTR per `placement_id`. ## Up to 10 per request You can request ads for up to 10 placements in one call. They share the same auction, so there's no rate-limit penalty to asking for everything on a page. ## No-fill is normal If the engine has no matching campaign for a slot, that slot returns `null` (or the array index is absent). Always render conditionally: ```tsx theme={null} {ads[0] && } ``` The fill rate depends on: * How relevant your content is to active campaigns * The `relevancy` threshold you set — see [request-ads → relevancy tradeoff](/ai-platforms/request-ads#relevancy-tradeoff) * Advertiser budget availability at the moment You can tune fill rate up by lowering the relevancy threshold on the `Gravity` constructor — but be aware tighter matches tend to convert better. ## Next How Gravity runs placement-scoped experiments on your traffic. # AI platform quickstart Source: https://docs.trygravity.com/ai-platforms/quickstart From zero to your first live ad in under five minutes. ## What you're going to do 1. Create an AI platform account and grab your API key. 2. Install the SDK. 3. Fire ad requests from your server in parallel with your LLM call. 4. Render the ad in your chat UI. 5. Drop the web pixel on your site. 6. Flip to production when it looks right. Copy the commands, change a couple of IDs, ship it. Throughout this guide we use the SDK (`@gravity-ai/api` + `@gravity-ai/react`). If you'd rather hit the HTTP API directly, see the [API Reference](/engine/introduction). Same engine, you just own the rendering and tracking. ## 1. Create your account 1. Sign up at [app.trygravity.com/publisher/signup](https://app.trygravity.com/publisher/signup). 2. Create an AI platform account for your app. 3. From your dashboard, copy the **API key** and the **Pixel ID**. Save the API key as `GRAVITY_API_KEY` in your server environment. ## 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. Prepare context on the client Call `gravityContext()` in your chat component so the server gets the session ID and **device signals** (the end user's User-Agent, IP, etc.) alongside the messages. ```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 }); // gravity_context includes a `device` object: { ua, timezone, locale, ... } // The server-side SDK adds the end-user IP from the request headers automatically. fetch('/api/chat', { method: 'POST', body: JSON.stringify({ messages, gravity_context }), }); ``` **Don't skip this — and forward `gravity_context` all the way to the ad request.** The `device` object's `ua` and `ip` are **required** — they power fraud/bot detection plus the **Device** and **Geography** breakdowns in your dashboard, and requests missing them are rejected with HTTP `400`. If your backend calls Gravity without forwarding the client's `device`, the ad request carries your *server's* UA/IP instead of the end user's. The SDK's `getAds(req, …)` reads `gravity_context` from the request body for you — just make sure your client actually posts it. See [Device signals](/ai-platforms/request-ads#device-signals). ## 4. Fetch ads on the server Fire the ad request **in parallel** with your LLM call. It never throws — failures return an empty 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 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") ``` ## 5. Render the ad ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; function ChatResponse({ response, ads }) { return (

{response}

{ads[0] && }
); } ``` `GravityAd` automatically handles the placement design, impression tracking, click attribution, disclosure, and fallback. ## 6. Install the pixel (required) The Gravity pixel is **required** for attribution and payouts. Without it, Gravity can't measure visitor journeys or tie ad performance back to real outcomes. Drop this on every page: ```html theme={null} ``` See the [Pixel page](/ai-platforms/pixel) for verification steps and the [in-app browser install](/ai-platforms/pixel#in-app-browser-webview) if your native app opens ad links in a WebView. ## 7. Verify, then go live By default, `new Gravity()` returns **test ads** — no billing, safe to iterate. You'll see the same test creative every time. When you're ready for real traffic: ```ts Node / JS / TS theme={null} const gravity = new Gravity({ production: true }); ``` ```python Python theme={null} gravity = Gravity(production=True) ``` That's it. You're now serving real ads and earning revenue. The long version: lifecycle, placements, error handling, framework-specific examples. Everything to verify before flipping `production: true`. ## What you didn't have to think about * **Retries** — the SDK handles transient failures silently. * **Timeouts**: 4 seconds by default. Configurable. * **Impression / click tracking** — fired by `GravityAd`. Attribution is automatic. * **Experiments** — Gravity can test designs without publisher code changes. See [Experiments](/ai-platforms/experiments). * **Ad quality** — Gravity handles the creative; you never hand-pick advertisers. ## Troubleshooting Expected when no campaign matches the conversation — the engine returns `204 No Content` and the SDK resolves to `{ ads: [] }`. Gracefully hide the ad slot. Lower the [relevancy threshold](/ai-platforms/request-ads#relevancy-tradeoff) to fill more aggressively if you want looser matches. You're still on test mode. Pass `production: true` to the constructor or set `production=True` in Python. If you're using `GravityAd`, it fires on visibility via IntersectionObserver — make sure the ad is actually rendering in the viewport. If you render ads with a custom component, you have to fire the impression pixel yourself: `new Image().src = ad.impUrl` when the ad becomes visible. # Request ads Source: https://docs.trygravity.com/ai-platforms/request-ads Server-side ad fetching — SDK and API examples, patterns, and options. ## The pattern Fire the ad request **in parallel** with your LLM call. Two reasons: 1. **Latency budget.** The user's perceived latency is your LLM stream — the ad request should never block it. 2. **Fire and forget.** Ad fetch can fail silently without affecting the user's chat. ```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" } }' ``` **`device.ip` and `device.ua` are required.** Ad requests missing either field are rejected with **HTTP `400`** (`{"detail": {"errors": ["Field 'device.ip' is required", "Field 'device.ua' is required"]}}`). They power fraud/bot detection, geo, and the Device breakdown. The browser SDK collects them via `gravityContext()`; **if you call the ad API server-side (most AI platforms do), you must forward the client-collected `device` through your backend** — the request reaching Gravity otherwise carries your server's UA/IP, not the end user's. See [Device signals](#device-signals). ## What's in the request | Field | Required | Description | | - | - | - | | `messages` | Yes | Recent conversation messages. Array of `{role, content}`. The engine uses the last few for contextual matching. | | `sessionId` | **Yes** | Your session identifier. Used for frequency capping and experiment bucketing. Enforced by the engine. **Free if the [Gravity pixel](/ai-platforms/pixel) is installed** — the pixel mints a `gr_sess_`-prefixed session ID (30-min idle, 1-day max) and the SDK auto-forwards it via `window.gravityPixel.getSessionId()` (requires `@gravity-ai/api` ≥ 1.1.7). Override with your own value if you have a better session scope. | | `placements` | Yes | Array of `{placement, placement_id}`. 1–10 per request. | | `user.userId` | **Yes** for JS SDK; Optional for Python SDK | Stable per-user identifier on your side (normalized to `id` on the wire). Used for frequency capping, attribution, and identity linking. The JS SDK's `gravityContext()` throws if missing — pass `"anonymous"` (or any constant) yourself if you don't have a real one. The Python SDK's server-side fallback defaults to `"anonymous"`. | | `user.email` / `user.phone` | Optional | See [User data & hashing](#user-data-&-hashing) below — raw values are hashed by the SDK before they leave the client. | | `device` | **Yes** (`ip` + `ua`) | End-user device signals — `ua` and `ip` are **required**; `country`, `os`, and more are optional. Requests missing `device.ip` or `device.ua` are rejected with HTTP `400`. Powers fraud/bot detection plus the Device and Geography breakdowns. Collected automatically by `gravityContext()` in the browser; **server-side callers must forward it** (see [Device signals](#device-signals) below). | | `excludedTopics` | Optional | Array of topic strings to exclude (e.g. `["politics"]`). | | `relevancy` | Optional | Minimum relevancy score 0.0–1.0. When omitted, the engine falls back to the publisher baseline configured in your dashboard; the SDKs default to `0.2`. See [Relevancy tradeoff](#relevancy-tradeoff) below. | | `testAd` | Optional | Defaults to `false`. When `true`, returns test creative and skips billing/metrics. The SDKs set this from the inverse of their `production` flag. | ## Device signals The `device` object is how Gravity verifies each request comes from a real device and attributes it to a location. `device.ip` and `device.ua` are **required on every ad request** — without them Gravity cannot run bot/fraud detection, so requests missing either are rejected with HTTP `400` and no ad is served. | Field | Type | Required | Description | | - | - | - | - | | `ua` | `string` | **Yes** | End-user User-Agent. Drives fraud detection and the **Device** breakdown (mobile / tablet / desktop). Send the real browser/app UA of the end user — not your server's. | | `ip` | `string` | **Yes** | End-user IP. Drives fraud detection and **Geography**. The SDK auto-fills this from the incoming request; direct HTTP callers must set it explicitly. | | `country` | `string` | No | 2-letter ISO country code. Optional override — Gravity also derives country from `ip`. | | `os` | `string` | No | Operating system (e.g. `iOS`, `Android`, `Windows`). | | `ifa` | `string` | No | Mobile advertising ID (IDFA / GAID), for app publishers. | | `id` | `string` | No | Your stable per-device identifier, for analytics/fraud. | Extra fields (`timezone`, `locale`, `browser`, `device_model`, …) are accepted and stored. ```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" } ``` **Native-app and non-browser clients:** if your iOS/Android app calls Gravity, send the app's real client UA (e.g. `CFNetwork/Darwin`, `okhttp`, `ktor-client` — Gravity recognizes common native-app clients as mobile) and the end user's IP. For IDE/CLI surfaces, send the client application's UA string. A pure server-to-server call must still forward the end user's `device.ip` and `device.ua` through your backend — requests carrying no end-user device signals are rejected with HTTP `400`. ### Forwarding device server-side The browser SDK's `gravityContext()` collects `device` on the client. Your client posts that context to your backend; your backend forwards it to Gravity. The SDK's `getAds(req, ...)` / `get_ads(request, ...)` read `gravity_context` (incl. `device`) from the request body and add the end-user `ip` from the incoming request headers automatically. If you build the ad request by hand, copy the client's `device` object into the body yourself. ## User data & hashing Passing the user's email or phone number on the ad request materially improves attribution and reporting — particularly view-through attribution, where a conversion on an advertiser's site ties back to an ad the user saw (not just clicked). **Raw email/phone never leaves the client.** The SDK hashes them (SHA-256, normalized form) before the request is sent: ```ts @gravity-ai/api theme={null} import { gravityContext, hashPii } from '@gravity-ai/api'; // SDK hashes client-side before the request leaves the browser. const hashed = await hashPii({ email: user.email, phone: user.phone }); const gravity_context = gravityContext({ sessionId: chatSession.id, user: { userId: user.id, ...hashed }, }); ``` ```python gravity_sdk theme={null} from gravity_sdk import Gravity # The Python SDK reads `user` from the client-posted request body under # `gravity_context.user`. Hash on the client (see the @gravity-ai/api # tab) — the server-side get_ads call stays clean. result = await gravity.get_ads(request, messages, placements) # Need to hash server-side? (e.g. webhooks, non-browser clients.) Post # the hashed fields as part of the `gravity_context.user` payload that # your own ad-request endpoint forwards to get_ads. The SDK's hash_pii # helper normalizes + SHA-256s: # # from gravity_sdk import hash_pii # hashed = hash_pii(email=current_user.email, phone=current_user.phone) # # ...merge `hashed` into the gravity_context.user dict your server # # sends through to this endpoint before calling gravity.get_ads(). ``` If you're calling the HTTP API directly and doing the hashing yourself, match the normalization: * **email:** `sha256(email.strip().lower())` * **phone:** `sha256(re.sub(r"\D+", "", phone))` (digits only) Then pass the email hash as `user.email_hash` (the canonical field — it matches the `email_hash` used by the [Index `/search` API](/gravity-index/search)) and the phone hash as `user.hashed_phone`. Don't send raw email or phone over the wire. The engine still accepts the legacy `user.hashed_email` alias and a raw `user.email` field for backward compatibility, but new integrations should send `user.email_hash`. ## What's in the response On a successful match the endpoint returns HTTP `200` with a **JSON array** of ad objects — one per requested placement: ```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 wrap this array in a convenience object — `{ ads, status, elapsed }` in JS, `AdResult(ads, status, elapsed_ms, ...)` in Python — but that envelope is SDK-only and does not appear on the wire. **Always** use `clickUrl` (not `url`) for ad links — it routes through tracking, records the click, and 302s to the landing page. **Always** fire `impUrl` when the ad becomes visible — in the SDK, `GravityAd` does this automatically via `IntersectionObserver`. When no ad matches (or the request is filtered as a bot, times out, or hits an unrecoverable error), the endpoint returns an **HTTP `204 No Content` with an empty body** — there is no JSON payload. Your UI should hide the slot gracefully. ## Framework examples ### Next.js (App Router) ```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 { messages } = await req.json(); const adPromise = gravity.getAds(req, 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 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 Request multiple placements in one call; they share 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' }, ]); // ads[0] goes in 'main', ads[1] in 'sidebar', ads[2] in 'inline-1' ``` Each slot can return an ad or `null`. Render conditionally. ## Tuning ### Relevancy tradeoff `relevancy` is a score from 0.0 to 1.0. Default is `0.2`. It's the **single most common misconfiguration** in Gravity integrations, so it's worth understanding the tradeoff: | Setting | What happens | When to use | | - | - | - | | Lower (e.g. `0.1`) | Looser contextual match. **Higher fill rate** — ads serve more often. Individual ads may feel less tied to the conversation. | Pages where you want to monetize every turn. New integrations where you want to see traffic flow. | | Higher (e.g. `0.4–0.5`) | Tighter contextual match. **Lower fill rate** — many turns return no ad. When you do serve, ads feel very on-topic. | Premium surfaces where you'd rather show nothing than show a loose match. | The default `0.2` is our recommendation for most integrations — enough match quality to feel relevant, enough fill to be worth monetizing. Start there and adjust if per-slot data tells you to. ```ts theme={null} const gravity = new Gravity({ production: true, relevancy: 0.35 }); ``` **Timeout.** Default 4 seconds, matching the engine's serving deadline. Raise if your stack can tolerate it; lower if you're strict on SLAs. ```ts theme={null} const gravity = new Gravity({ production: true, timeoutMs: 5000 }); ``` **Excluded topics.** Per-request: ```ts theme={null} await gravity.getAds(req, messages, placements, { excludedTopics: ['politics', 'religion'] }); ``` ## Next Render them in your UI. Full HTTP surface with all parameters. # Show ads Source: https://docs.trygravity.com/ai-platforms/show-ads Render Gravity ads with the official React component. ## React ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; {ads[0] && } ``` `GravityAd` handles the placement design, impression and click tracking, disclosure, feedback controls, and fallback automatically. Pass the API key used for the ad request to enable feedback submissions: ```tsx theme={null} ``` `apiKey` is visible in the browser and used for feedback submissions. Pass it down from your server with the ad response rather than referencing a server-only environment variable in client code. ## Multiple placements The response order matches the placement order in your request. Render each slot only when it fills: ```tsx theme={null} {ads[0] && } {ads[1] && } ``` ## Custom rendering Use the official component unless you need a fully custom renderer. Custom renderers must: 1. Open `ad.clickUrl`, not the raw landing-page URL. 2. Request `ad.impUrl` exactly once when the ad becomes visible. 3. Show a clear ad disclosure. See the [JavaScript and React SDK guide](/sdks/javascript) for request setup. # Impression & click tracking proxy Source: https://docs.trygravity.com/ai-platforms/tracking-proxy Route impression and click tracking through your own domain for first-party data visibility. When Gravity serves an ad, the response includes two tracking URLs: * **`impUrl`** — fired when the ad becomes visible (impression pixel) * **`clickUrl`** — navigated to when the user clicks the ad By default these point to `api.trygravity.ai`. A **tracking proxy** lets you route them through your own domain instead, so you can log every impression and click on your own server before forwarding to Gravity. This is the same pass-through pattern advertisers use for [first-party conversion tracking](/advertisers/server-conversions) — applied to the publisher side. ## Why proxy tracking? * **First-party data** — impressions and clicks hit your domain, so you can log them in your own analytics, data warehouse, or billing system. * **Verification** — independently verify the impression and click counts Gravity reports. * **Latency visibility** — monitor tracking endpoint performance from your own infrastructure. * **No SDK changes required** — the SDK fires whatever URL is on `impUrl` / `clickUrl`. You just rewrite them on your server before passing the ad to your client. ## How it works Add a route on your server (e.g. `/gravity/`) that forwards requests to `api.trygravity.com`. On your server, after calling `gravity.getAds()`, replace the Gravity domain in `impUrl` and `clickUrl` with your own proxy domain. Your proxy receives every impression and click request. Log whatever you need, then forward 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 ### 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 tracking URLs After calling `gravity.getAds()` on your server, replace the Gravity domain in the ad response with your proxy path before sending 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 ```python theme={null} import re ads = await gravity.get_ads(request, messages, placements) PROXY_BASE = "https://yourapp.com/gravity" for ad in ads: if ad.imp_url: ad.imp_url = re.sub(r"^https://api\.trygravity\.(com|ai)", PROXY_BASE, ad.imp_url) if ad.click_url: ad.click_url = re.sub(r"^https://api\.trygravity\.(com|ai)", PROXY_BASE, ad.click_url) ``` The encrypted `?p=` token in each URL is opaque and tamper-proof. You're only changing the domain prefix, so all billing and attribution still works exactly as before on Gravity's side. ## 3. Log impressions and clicks Your proxy server receives every tracking request. Here's what each path tells you: | Path | Event | When it fires | | - | - | - | | `/ack` or `/track/imp` | Impression | Ad became visible (IntersectionObserver, 50% threshold) | | `/track/click` | Click | User clicked the ad | The `?p=` query parameter is an encrypted payload containing the click ID, campaign ID, publisher ID, ad ID, and price. You can't decrypt it (it's encrypted with Gravity's key), but you can count requests, log timestamps, and correlate with your own session/user data from request headers. ### Example: structured logging ```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 don't need a full proxy, the React SDK has `onImpression` and `onClick` callbacks that fire client-side: ```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(), }), }); }} /> ``` This is simpler but runs in the browser. The proxy approach gives you server-side visibility and works with any rendering method (not just the React SDK). ## Important notes * **Always forward `X-Forwarded-For`** — Gravity uses the end user's IP for geo-targeting and fraud detection. Without it, all impressions appear to come from your server's IP. * **Forward `User-Agent`** — used for device-type classification and bot filtering. * **Don't cache** — every impression and click request must reach Gravity. Impressions are deduplicated server-side, but a cached response would silently drop billing events. * **Click redirects** — the `/track/click` endpoint returns a `302` redirect to the advertiser's landing page. Make sure your proxy passes through the redirect response (don't follow it server-side). How impression and click tracking works in the SDK. How your tracked impressions translate to revenue. # Ad Feedback Source: https://docs.trygravity.com/engine/ad-feedback POST /ad-feedback Let users react to ads with thumbs-up / thumbs-down. End users can submit feedback on served ads — thumbs up, thumbs down, and optional reasons. This helps Gravity improve ad quality and relevancy over time, and gives publishers a signal for user satisfaction. ## Base URL ``` https://platform.trygravity.com ``` ## Headers Your Gravity publisher API key. ## Body Session identifier. Same value you passed when requesting the ad. The `impUrl` from the ad response object. The server decrypts this to extract the ad's tracking IDs (grclid, gradid, campaignId, publisherId) automatically — you don't need to parse or extract anything yourself. User's reaction. One of `thumbs_up` or `thumbs_down`. Optional. Why the user reacted this way. Array of reason strings. Suggested values: | Reason | When to use | | - | - | | `not_interested` | User doesn't care about this product/category | | `irrelevant` | Ad doesn't relate to the conversation | | `offensive` | Ad content is objectionable | | `repetitive` | User has seen this ad too many times | | `misleading` | Ad claims don't match the landing page | | `helpful` | (thumbs up) Ad was useful or well-matched | You can send any string — the values above are suggestions, not an enum. Optional. Free-text feedback from the user (max 1,000 characters). Optional. Placement type where the ad was shown (e.g. `below_response`). Optional. Publisher's slot identifier for the placement. Optional. Publisher's end-user identifier. Optional. Device signals — same shape as the ad request's `device` object. ```bash theme={null} curl -X POST https://platform.trygravity.com/ad-feedback \ -H "X-API-Key: " \ -H "Content-Type: application/json" \ -d '{ "sessionId": "sess_abc123", "impUrl": "https://api.trygravity.ai/ack?p=", "sentiment": "thumbs_down", "reasons": ["not_interested", "repetitive"] }' ``` ## Response **`200`** — feedback recorded. ```json theme={null} { "status": "ok", "feedbackId": "f47ac10b-58cc-4372-a567-0e02b2c3d479" } ``` **`401`** — invalid API key. **`422`** — invalid `impUrl`. The URL could not be decrypted. Make sure you're sending the exact `impUrl` from the ad response. **`429`** — rate limit exceeded. The endpoint allows up to 30 feedback submissions per session per minute. **`503`** — internal error. Safe to retry. ## Server-prompted feedback Official Gravity SDKs show and submit feedback controls automatically when requested by the server. No publisher setup is required. Custom renderers can use `feedbackPrompt` to decide when to show controls and POST the response to this endpoint. Feedback interactions must not count as ad clicks. ## Best practices ### Surface feedback controls after the ad renders Show a small thumbs-up / thumbs-down UI once the ad is visible. Don't interrupt the user — feedback should be one tap, not a modal. ```tsx theme={null} function AdWithFeedback({ ad, sessionId, apiKey }) { const [sent, setSent] = useState(false); const sendFeedback = async (sentiment, reasons) => { setSent(true); await fetch('https://platform.trygravity.com/ad-feedback', { method: 'POST', headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json', }, body: JSON.stringify({ sessionId, impUrl: ad.impUrl, sentiment, reasons, }), }); }; if (sent) return Thanks for the feedback; return (
); } ``` ### Collect reasons on thumbs-down When a user taps thumbs-down, show a quick follow-up with checkboxes or chips for `not_interested`, `irrelevant`, `repetitive`, `offensive`. Send the selected reasons in the `reasons` array. This gives Gravity actionable signal to improve matching. ### Don't gate the experience on feedback Feedback is fire-and-forget. If the request fails, swallow the error — never block the user's workflow because a feedback call didn't land. ### Just pass through `impUrl` Every ad response includes an `impUrl`. Simply store the ad object and pass `ad.impUrl` back when submitting feedback — the server handles all the tracking ID extraction automatically. No need to parse URLs or decrypt tokens on the client side. ## What's next Render ads with the pre-built React component or your own UI. Full request / response reference for `POST /api/v1/ad`. # Contextual Ads Source: https://docs.trygravity.com/engine/contextual-ads POST /api/v1/ad Get ads matched to conversation context. The primary ad endpoint. Send conversation messages and get back a contextually matched ad with generated creative. ## Headers Your Gravity API key. Format: `Bearer ` ## Body Conversation history. Array of `{role, content}` message objects. The engine uses the last few messages for contextual matching. Session identifier. Used for frequency capping, experiment bucketing, and reporting. If the [Gravity pixel](/ai-platforms/pixel) is installed and you're on `@gravity-ai/api` ≥ 1.1.7, the SDK auto-forwards the pixel's `gr_sess_`-prefixed session ID (30-min idle timeout, 1-day max) via `window.gravityPixel.getSessionId()`. Pass your own `sessionId` to override if you have a better session scope. Ad placement configuration. 1–10 placements per request. Placement 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`. Unique slot identifier on your side. Ties dashboard analytics back to the spot in your UI. Must be unique **within a request** — duplicates are rejected. Cap on generated ad-copy length for this slot, 50–5000. Overrides the placement's configured baseline. Optional. User context for targeting and attribution. All fields are optional on the wire — the engine accepts any shape and passes extras through. Stable per-user identifier on your side. Used for frequency capping, attribution, and identity linking. The JS SDK sends this from `user.userId` and defaults it to `"anonymous"` if you don't supply one. User's IP address. Auto-populated by the SDK from the incoming request. SHA-256 of `email.strip().lower()`. Canonical email field (matches the `email_hash` used by the Index `/search` API). Passing this significantly improves attribution; see [Data quality](/introduction/concepts#data-quality). The legacy `hashed_email` alias and a raw `email` field are still accepted for backward compatibility. SHA-256 of digits-only phone. Extra fields (gender, age, subscription tier, interests, etc.) are accepted and stored as request context. End-user device signals. **`ip` and `ua` are required** — requests missing either are rejected with HTTP `400` (see [Errors](#errors)). They power fraud/bot detection plus the Device and Geography breakdowns in your dashboard. The browser SDK auto-populates them via `gravityContext()`; **server-side callers must forward the client-collected `device`** (the request reaching Gravity otherwise carries your server's UA/IP, not the end user's). End-user User-Agent. Drives fraud detection and the mobile/tablet/desktop split. Send the end user's real browser/app UA — not your server's. End-user IP. Drives fraud detection and geo-targeting. Auto-filled by the SDK from the incoming request; set it explicitly for direct HTTP. 2-letter ISO country code. Optional override — also derived from `ip`. Operating system (e.g. `iOS`, `Android`, `Windows`). Mobile advertising ID (IDFA / GAID), for app publishers. Your stable per-device identifier. Extra fields (`timezone`, `locale`, `browser`, `device_model`, …) are accepted and stored. Native-app/non-browser clients should send their real client UA (`CFNetwork/Darwin`, `okhttp`, `ktor-client`, …) plus the end user's IP. See [Request ads → Device signals](/ai-platforms/request-ads#device-signals). Optional. Minimum relevancy threshold, 0.0–1.0. When omitted, the engine falls back to the publisher baseline configured in your dashboard. Both SDKs default to `0.2`. Optional. Array of topic strings to exclude from matching (e.g. `["politics"]`). Optional. When `true`, returns test creative and skips billing/metrics. The SDKs set this from the inverse of their `production` flag. Optional, test requests only (`testAd: true`). Forces a sample format so you can build and check your UI on demand. | Value | Returns | | - | - | | `"image"` | A sample ad with `brandImage` and an image layout in `renderer_spec`, even if you don't use server-rendered designs. | | `"lead_form"` | A sample ad with a `leadForm` envelope. Submissions are discarded. | | `"iframe"` | A sample iframe creative (requires `supportsFrames: true`). | Combine with `alwaysReturn: true` to get an ad on every request. With `"image"`, pass `adLayout` (`"image_left"`, `"image_right"` or `"image_top"`) to preview a specific layout; the default is `"image_right"`. Live traffic ignores `adLayout`, since Gravity picks the layout per publisher. Optional. The server-rendered design vocabulary your client supports. Official SDKs set this automatically. | Capability | Meaning | | - | - | | `"psp1"` | Native iOS or Android renderer. | | `"web1"` | Web renderer. | Set `supportsSpec: false` to opt out of server-rendered designs. The legacy `supportsSpec: true` value is treated as web support. Optional. Privacy/consent signals for the end user. When omitted, behavior is unchanged from today. | Field | Type | Description | | - | - | - | | `gdprApplies` | `boolean` | Whether GDPR applies to this user (EU/EEA/UK). | | `tcfString` | `string` | IAB TCF consent string collected by your CMP. | | `usPrivacy` | `string` | US privacy (CCPA/CPRA) string, e.g. `"1YNN"`. | When `gdprApplies` is `true` and no valid `tcfString` is present, the engine serves **contextual-only** ads: it skips all processing keyed to pseudonymous user identifiers for that request (personalization, identity-keyed telemetry). Contextual matching — Gravity's core model — needs no personal data, so ads still serve. ```bash theme={null} curl -X POST https://server.trygravity.com/api/v1/ad \ -H "Authorization: Bearer " \ -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"}, "device": { "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...", "ip": "203.0.113.42" } }' ``` ## Response On a successful match the endpoint returns HTTP `200` with a **JSON array** of ad objects — one per requested placement: ```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 wrap this array in a convenience object — `{ ads, status, elapsed }` in JS, `AdResult(ads, status, elapsed_ms, ...)` in Python — but that envelope is SDK-only and does not appear on the wire. ### Ad object | Field | Type | Description | | - | - | - | | `adText` | `string` | Generated ad copy, contextually matched | | `title` | `string` | Product/campaign title | | `brandName` | `string` | Advertiser brand name | | `cta` | `string` | Call to action text | | `url` | `string` | Landing page URL | | `favicon` | `string` | Brand favicon URL | | `brandImage` | `string` | Product image URL, or `""` when the ad has no image — see [Image ads](#image-ads) | | `clickUrl` | `string` | Tracked click URL — use this for links | | `impUrl` | `string` | Impression pixel URL — fire when ad is visible | | `placement` | `string` | Echoes back the placement type this ad filled | | `placement_id` | `string` | Echoes back your slot correlation ID | | `campaignId` | `string` | Campaign identifier for the matched ad | | `renderer_spec` | `object` | Server-delivered ad-unit layout — see [Server-rendered ads](/sdks/server-rendered-ads) | | `feedbackPrompt` | `boolean` | When `true`, the SDK shows a one-tap thumbs-up/down prompt under the ad — see [Ad feedback](/engine/ad-feedback#server-prompted-feedback). Absent on most ads. | Null fields are omitted from the response. ### `renderer_spec` Gravity configures server-rendered designs per placement. When a compatible design is available, the response includes `renderer_spec`; otherwise the SDK uses its built-in card. Official SDKs handle this automatically. Contact [support@trygravity.com](mailto:support@trygravity.com) to configure a custom placement design. ### Image ads When the matched campaign has a product image, the ad includes `brandImage` (the image URL) and a `renderer_spec` that lays it out as an image card: the image on one side, the copy on the other, separated by a divider. Ads without an image return `brandImage: ""` and render as the normal text card. * **Official SDKs** (`@gravity-ai/react` 1.3.0+, `@gravity-ai/ui` 0.3.0+, `@gravity-ai/react-native` 0.2.0+) render the image card automatically. Older versions ignore it and show the text card. * **Raw API integrations** can ignore `renderer_spec` and draw their own design from `brandImage`, `title`, `adText`, `cta` and `clickUrl`. * The layout (`image_left`, `image_right` or `image_top`) is chosen server-side per publisher. You don't need to send anything. To get an image ad on demand while building your UI: ```json theme={null} { "testAd": true, "alwaysReturn": true, "testAdFormat": "image" } ``` When an [experiment](/ai-platforms/experiments) is active, the ad object also includes: | Field | Type | Description | | - | - | - | | `variant` | `string` | Human-readable arm label | | `experiment_id` | `string` | Canonical experiment ID | | `composition_id` | `string` | Per-render composition ID (unique per impression) | | `renderer_key` | `string` | Which renderer to use | | `composition` | `object` | Tokens and props for the assigned renderer | Experiment identity is already baked into `impUrl` and `clickUrl` — downstream analytics join automatically. Always use `clickUrl` for ad links (not `url` directly) and fire `impUrl` when the ad becomes visible. This ensures accurate tracking and billing. ## No ad available When no ad matches the context (or the request is filtered as a bot, times out, or hits an unrecoverable error), the endpoint returns an **HTTP `204 No Content` with an empty body** — there is no JSON payload. Your UI should gracefully hide the slot in that case. ## Errors Requests missing required fields are rejected with **HTTP `400`** and a JSON body naming each missing field. `device.ip` and `device.ua` are validated on every ad request (blank/whitespace-only values count as missing): ```json theme={null} { "detail": { "errors": [ "Field 'device.ip' is required", "Field 'device.ua' is required" ] } } ``` Fix the request by forwarding the end user's real IP and User-Agent — see [Request ads → Forwarding device server-side](/ai-platforms/request-ads#forwarding-device-server-side). # API Reference Source: https://docs.trygravity.com/engine/introduction The Gravity HTTP API — what's public, how to authenticate, what to call. Gravity's public API is intentionally small: | Endpoint | Host | Purpose | | - | - | - | | `POST /api/v1/ad` | `server.trygravity.com` | Fetch a contextually matched ad. Primary surface. | | `POST /ad-feedback` | `platform.trygravity.com` | Submit end-user feedback on a served ad. Authenticated with `X-API-Key`. | | `GET /health` | `server.trygravity.com` | Availability probe. | If you're using an SDK, you're already hitting `/api/v1/ad` under the hood — the SDK is a thin wrapper that handles context preparation, retry, and impression tracking. ## Base URL ``` https://server.trygravity.com ``` ## Authentication Pass your Gravity API key as a Bearer token: ```bash theme={null} curl -X POST https://server.trygravity.com/api/v1/ad \ -H "Authorization: Bearer $GRAVITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ ... }' ``` Grab the key from your [dashboard](https://app.trygravity.com) under **Settings → Platform Settings** (for AI platforms) or **Settings → Organization** (for advertisers). ## Health check ```bash theme={null} curl https://server.trygravity.com/health # → {"status":"ok","ready":true,"release_sha":"221d530…"} ``` Returns `200` with `status: "ok"`, `ready: true`, and the deployed `release_sha` in the steady state, or `503` with `{"status":"starting","ready":false}` while the server is still warming up. Useful for uptime probes. Doesn't count against rate limits. ## What's next Full request / response reference for `POST /api/v1/ad`. Let users react to ads with thumbs-up / thumbs-down. # CLI Source: https://docs.trygravity.com/gravity-index/cli Search the Index from the terminal Search the Gravity Index from the command line. The standalone CLI currently ships from the Gravity repo, so use this path if you have repo access; public agent integrations should use the npm MCP server. ## Install From a local Gravity checkout: ```bash theme={null} cd gravity mkdir -p ~/.local/bin cp index/cli/gravity-index ~/.local/bin/gravity-index chmod +x ~/.local/bin/gravity-index pip install --user httpx ``` Make sure `~/.local/bin` is on your `PATH` (add `export PATH="$HOME/.local/bin:$PATH"` to your shell rc). Requires Python 3.9+. Full install options (MCP, npm wrapper, API) are on the [Install page](/gravity-index/install). ## Optional configuration ```bash theme={null} # Override the API URL (defaults to https://index.trygravity.com) export GRAVITY_INDEX_URL=https://index.trygravity.com ``` ## Commands ### Search ```bash theme={null} gravity-index search "I need a serverless database" # With publisher identity metadata for attribution/matching 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"}' ``` Returns the recommendation with reasoning and a tracked click link, all color-formatted for the terminal. ### Follow-up ```bash theme={null} gravity-index search "does it support branching?" --follow-up ``` ### Browse ```bash theme={null} # All services gravity-index browse # Filter by category gravity-index browse --category Database # Search gravity-index browse "vector" ``` ### Service details ```bash theme={null} gravity-index info supabase ``` ## Publisher attribution Set `GRAVITY_PUBLISHER_KEY` so requests and integrations attribute to your platform: ```bash theme={null} export GRAVITY_PUBLISHER_KEY=your-publisher-api-key gravity-index search "auth for Next.js" ``` Optional private ranking modes for publisher-owned integrations: ```bash theme={null} export GRAVITY_INDEX_MONETIZATION_MODE=boost_cpa # relevance-first, small boost for CPA-backed tools ``` The CLI does not print economics. # Conversation context Source: https://docs.trygravity.com/gravity-index/conversation-context Pass your session ID and Index recommends from the whole conversation, not just the current query By default, send your own stable conversation ID as `external_session_id` on every `/search` call. When Gravity holds a current summary of that conversation, Index uses it as private supporting context: your `query` stays the current task, the summary explains what the user has been trying to do. You never replay prior turns inside a search request. ```json theme={null} { "query": "Recommend a hosted database for this application.", "external_session_id": "chat_8f3a" } ``` If no summary exists yet (or it has expired; summaries live about two hours after the last turn), `/search` runs on the explicit query alone. The summary is never returned in the response. Like every Gravity conversation summary it is kept, scoped to your publisher account, as a summary embedding and in Gravity's warehouse for ranking and reporting; the raw turns are not. ## How Gravity gets the conversation Stream each user and assistant turn to the ingestion endpoint with your publisher key. This does not serve an ad or record an impression, and it is separate from the `sessionId` you may already send on [`/api/v1/ad`](/ai-platforms/request-ads): Index summaries are keyed to your publisher account and are only built from messages you post here. ```bash theme={null} curl -X POST https://server.trygravity.com/api/v1/index/session/messages \ -H 'content-type: application/json' \ -H "Authorization: Bearer $GRAVITY_PUBLISHER_KEY" \ -d '{ "session_id": "chat_8f3a", "message": { "id": "msg_0021", "role": "user", "content": "I need a hosted Postgres database for my Next.js app." } }' ``` Returns `202 Accepted` once the message is durably queued. `message.id` must be stable per turn so retries are deduplicated; `role` is `user` or `assistant`. Requests carrying `Sec-GPC: 1` return `204` and store nothing. `session_id` is up to 200 characters, the same value you pass as `external_session_id` on `/search` and `/shop/search`. ## Search with only the session When the agent has no query of its own (it just wants "what fits this conversation") call `POST https://index.trygravity.com/session/search` with the session ID and your `X-API-Key`: ```json theme={null} { "session_id": "chat_8f3a", "max_options": 5 } ``` The response has the same shape as `/search` (recommendation, reasoning, options, tracked click URL). A `409 session context is not ready` means no summary exists yet; retry after the next turn is processed. # For Service Providers Source: https://docs.trygravity.com/gravity-index/for-service-providers Get listed, earn on every agent-driven signup The Gravity Index is a recommendation surface used by AI agents and platforms: coding agents inside Cursor, Claude Desktop and Claude Code, plus custom agents and chat products. When an agent recommends your service, the user gets the reasoning and a tracked link to your signup page. ## Why list in the Gravity Index Agents call `gravity_index_search` natively via MCP. You show up the moment a developer asks their agent for "a database" or "payments for Next.js". Platforms can share a conversation summary with the Index, so you are recommended when the user actually has the problem you solve, not on a keyword match. No CPC, no CPM. You define a cost per successful signup and pay only after a confirmed conversion or completed integration. ## How it works Send us your service details (slug, name, category, description, tags) and a signup landing page URL. We review + add it to the catalog. When a developer's agent calls `gravity_index_search("managed Postgres with branching")`, the Index's reasoning layer may recommend you with an explanation of why. The response includes the reasoning, a `click_url`, and a `grclid`. The click link redirects the user to your signup page and records attribution; it does not create a conversion or CPA charge by itself. The user signs up on your platform. You confirm it through your conversion feed, and if your listing has an explicit CPA configured, that confirmed conversion creates the CPA charge. ## Economics Each listing configures `default_cpa_cents` — the cost charged per confirmed conversion. Private platform ranking can prefer CPA-eligible listings when quality ties. Agent-facing output still optimizes for relevance and does not expose economics. ## Improving your listing Once listed, three things move the needle: 1. **A tight description**: one or two sentences on what you do and who it is for. The reasoning layer reads this when deciding whether you fit the user's problem. 2. **Accurate category and tags**: these decide which searches you are a candidate for. 3. **A signup page that matches the pitch**: the user arrives from a recommendation that explained why you fit; the landing page should confirm it fast. ## Badge & attribution We'll provide a "Available via Gravity Index" badge for your docs. Linking back is optional, but it helps developers find us. ## Get listed Email us at `index@trygravity.ai` or open a PR against the Gravity Index catalog (contact us for the slug reservation flow). # Install Source: https://docs.trygravity.com/gravity-index/install Get the Gravity Index working in your agent, editor, or terminal in 60 seconds Pick the surface that fits how you code. | You use… | Install | Time | | - | - | - | | **AI coding agent** (Cursor, Claude Code, Claude Desktop, Windsurf, etc.) | [MCP server → npm](#mcp-server-recommended) | 30 sec | | **Terminal / shell scripts** | [Bash CLI](#bash-cli) | 30 sec | | **Custom integration** (your own agent) | [Direct API](#direct-api) | — | Production calls require a publisher key so searches, setup links, and completed integrations attribute back to the embedding platform. If you're evaluating the Index, ask Gravity for a publisher key first. *** ## MCP server (recommended) The Gravity Index ships as an MCP server so your agent can call it as a native tool — no HTTP client code, no prompt engineering. ### One-liner install (npm) The npm wrapper is zero-dependency and runs via `npx`. No Python, no clone, no build. ```bash Claude Code theme={null} claude mcp add gravity-index -- 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"] } } } ``` ```json Cursor theme={null} // Settings → MCP → Add new MCP server { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"] } } } ``` ```json Windsurf theme={null} // Settings → Cascade → MCP servers { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"] } } } ``` Package: [`@gravity-ai/index-mcp`](https://www.npmjs.com/package/@gravity-ai/index-mcp) · Source: [`sdk-js/packages/index-mcp`](https://github.com/Try-Gravity/gravity/tree/main/sdk-js/packages/index-mcp) ### Python alternative If you already have `uv` / Python 3.12 and prefer to run from source: ```bash theme={null} git clone https://github.com/Try-Gravity/gravity.git cd gravity claude mcp add gravity-index \ -e GRAVITY_INDEX_URL=https://index.trygravity.com \ -- uv run 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 After adding the server, restart Cursor / Claude / Windsurf. You should see 5 new tools: * `gravity_index_search` — LLM-driven "I need X" query * `gravity_index_browse` — list services, filter by category, tag search * `gravity_index_list_categories` — enumerate all 22 categories with counts * `gravity_index_get_service` — deep lookup by slug * `gravity_index_report_integration` — tell the Index a user finished an integration ### Test it Ask your agent something like: *"I need to add email sending to my Next.js app — search the Gravity Index for options."* *** ## Bash CLI Search the Index from your terminal. Useful for shell scripts, CI, or quick ad-hoc queries. The standalone CLI currently ships from the Gravity repo, so use this path if you have repo access; public agent integrations should use the npm MCP server above. ### Install ```bash theme={null} # From a local Gravity checkout: cd gravity mkdir -p ~/.local/bin cp index/cli/gravity-index ~/.local/bin/gravity-index chmod +x ~/.local/bin/gravity-index # Make sure ~/.local/bin is on PATH (add to ~/.zshrc or ~/.bashrc): export PATH="$HOME/.local/bin:$PATH" # One-time dependency (the CLI uses httpx for HTTP) pip install --user httpx # or: uv tool install httpx ``` Requires Python 3.9+. ### Use ```bash theme={null} # Search gravity-index search "I need a serverless database" # Search with publisher identity metadata for attribution/matching 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 gravity-index browse gravity-index browse --category Database gravity-index browse "vector" # Lookup gravity-index info supabase ``` Returns color-formatted terminal output with the recommendation, reasoning, and a tracked click URL. *** ## Direct API If you're building your own agent or want to call the Index from any language, it's a plain REST API at `https://index.trygravity.com`. ```bash theme={null} # Health curl https://index.trygravity.com/health # Search curl -X POST https://index.trygravity.com/search \ -H 'content-type: application/json' \ -H "X-API-Key: $GRAVITY_PUBLISHER_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 curl -H "X-API-Key: $GRAVITY_PUBLISHER_KEY" 'https://index.trygravity.com/services' curl -H "X-API-Key: $GRAVITY_PUBLISHER_KEY" 'https://index.trygravity.com/services/supabase' ``` Full API reference: [/gravity-index/search](/gravity-index/search) and [/gravity-index/products](/gravity-index/products). *** ## Publisher attribution If you're a platform publisher embedding the Gravity Index in your own AI app, set a `GRAVITY_PUBLISHER_KEY` so requests and completed integrations from your users attribute to your account. For best attribution, send stable per-user/session metadata on every `/search` call: | Field | Type | Notes | | - | - | - | | `external_session_id` | string | Your stable chat/session ID. Also unlocks [conversation context](/gravity-index/conversation-context): Index reads that session's summary as private supporting context | | `external_user_id_hash` | string | Hash of your user ID; preferred over raw IDs | | `external_user_id` | string | Optional raw user ID; Gravity stores only a SHA-256 hash | | `hashed_email` | string | SHA-256 of the user's normalized email, when available (canonical; legacy `email_hash` still accepted) | | `metadata` | object | Non-sensitive context such as surface, plan, workspace, or placement | ```json MCP (npm) theme={null} { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"], "env": { "GRAVITY_PUBLISHER_KEY": "your-publisher-api-key" } } } } ``` ```bash CLI theme={null} export GRAVITY_PUBLISHER_KEY=your-publisher-api-key gravity-index search "auth for Next.js" ``` ```bash Direct API theme={null} curl -X POST https://index.trygravity.com/search \ -H 'content-type: application/json' \ -H 'X-API-Key: your-publisher-api-key' \ -d '{ "query": "...", "external_session_id": "session_123", "external_user_id_hash": "sha256_user_id", "hashed_email": "sha256_email", "metadata": {"surface": "chat"} }' ``` [Get your publisher key →](/gravity-index/for-service-providers) *** ## Troubleshooting Make sure you have Node 18+ installed: `node -v`. Then try a manual pre-install: `npm install -g @gravity-ai/index-mcp && which gravity-index-mcp`. If that works, replace the `npx` command in your config with the absolute path. Check the MCP server logs: * **Claude Desktop**: `~/Library/Logs/Claude/mcp*.log` (macOS) * **Cursor**: Settings → MCP → check status next to `gravity-index` * **Claude Code**: `claude mcp list` to see running servers Most common cause: `npx` can't reach the npm registry. Test with `npx -y @gravity-ai/index-mcp --help` in a terminal. The CLI needs `httpx`. Install it with `pip install --user httpx` or, if you use `uv`, `uv tool install httpx`. The `/search` endpoint uses an LLM to pick the best service, so cold starts can take 2 to 4 seconds. Subsequent queries within a session are usually \< 1s. If you're seeing consistent timeouts, check `https://index.trygravity.com/health` and open an issue at [github.com/Try-Gravity/gravity/issues](https://github.com/Try-Gravity/gravity/issues). # Gravity Index Source: https://docs.trygravity.com/gravity-index/introduction Recommendation API for AI agents and platforms. Developer services and consumer products. The Gravity Index is a discovery API that AI agents call when a user needs something the agent can't produce itself: a database, an email API, a pair of running shoes. One publisher key, two verticals: | Vertical | Endpoint | Returns | | - | - | - | | **Developer services** | [`POST /search`](/gravity-index/search) | one reasoned recommendation with a tracked link | | **Consumer products** | [`POST /shop/search`](/gravity-index/products) | a ranked list of in-stock products with price, image, and a tracked buy link | For developer services it's not a search engine; it's a reasoning agent that understands what you're building and recommends the best tool, with the reasoning behind the pick. For products it's structured retrieval: filters for brand, merchant, price and sale status, ranked by relevance. ## Who can integrate Any approved Gravity publisher. Your existing publisher API key works on every Index endpoint the moment your account is approved for production traffic; there is no separate Index sign-up or entitlement. Blocked accounts get `403` on every Index call, the same gate that applies to production ads. ## How it works Your AI coding agent calls `POST /search` with a natural language query like "I need a serverless database with a free tier." The Gravity Index analyzes the catalog, considers your constraints, and picks the best match — explaining **why** it's the right choice. The response includes the recommended service, the reasoning, and a tracked link. The agent surfaces it to the user in its own words. The user clicks the tracked link to sign up with the service. Attribution is recorded on that click; billing only happens after a confirmed conversion. ## Set up in 30 seconds Pick your surface (MCP server, bash CLI, or direct API) and get running in under a minute. ## Interfaces `npx -y @gravity-ai/index-mcp` — native tool for Cursor, Claude Code, Claude Desktop, Windsurf `gravity-index search "database"` — terminal + scripts `POST index.trygravity.com/search` for custom integrations ## Base URL ``` https://index.trygravity.com ``` ## Key concepts * **search\_id** — every search gets a unique ID. Pass it back for follow-up questions. The Index remembers context. * **Click link**: a tracked short URL the user clicks to sign up with the recommended service. It mints a `grclid` for attribution; billing only happens after a confirmed conversion. * **Conversation context**: pass your stable `external_session_id` and the Index uses that session's cached summary as private supporting context. See [Conversation context](/gravity-index/conversation-context). * **No auction** — pure relevance matching with reasoning. Not bidding. # MCP Server Source: https://docs.trygravity.com/gravity-index/mcp AI agents call the Gravity Index natively The Gravity Index ships as an MCP server so AI coding agents can call it as a native tool — no HTTP client code needed. ## Install (recommended: npm) One-liner — no Python, no clone, no build. ```json Claude Desktop theme={null} // ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) // %APPDATA%\Claude\claude_desktop_config.json (Windows) { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"] } } } ``` ```json Cursor theme={null} // Settings → MCP → Add server { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"] } } } ``` ```bash Claude Code theme={null} claude mcp add gravity-index -- npx -y @gravity-ai/index-mcp ``` ### Required publisher key Pass your publisher key so Index requests and completed integrations attribute to your platform: ```json theme={null} { "mcpServers": { "gravity-index": { "command": "npx", "args": ["-y", "@gravity-ai/index-mcp"], "env": { "GRAVITY_PUBLISHER_KEY": "your-publisher-api-key" } } } } ``` Optional private ranking is configured with `GRAVITY_INDEX_MONETIZATION_MODE=boost_cpa` — relevance-first ranking that gives a small boost to services with an active CPA campaign. MCP tool output does not expose economics. ## Install (Python, for self-hosted setups) The reference implementation lives in the Gravity monorepo at `index/mcp/gravity_index_mcp.py`. Exposes the same tools, identical behaviour. ```bash theme={null} claude mcp add gravity-index \ -e GRAVITY_INDEX_URL=https://index.trygravity.com \ -- python3 /path/to/gravity_index_mcp.py ``` ## Tools Five tools. All return plain-text markdown the agent can display directly. ### `gravity_index_search` Search for the best tool for a development task, or (with `vertical: "consumer"`) the best retail product. Developer results are a reasoned recommendation with a tracked signup link; consumer results are products with merchant, price, image and a buy link (see [Product search](/gravity-index/products)). | Parameter | Type | Required | Description | | - | - | - | - | | `query` | string | Yes | What you need, e.g. `"serverless database with free tier"` or `"men's Nike running shoes"` | | `vertical` | string | No | `developer` (default), `consumer`, or `auto` to let the Index route | | `filters` | object | No | Consumer hard filters: `brands[]`, `merchants[]`, `price_min`, `price_max`, `currency`, `sizes[]`, `colors[]`, `gender`, `categories[]`, `on_sale_only` | | `search_id` | string | No | Continue a previous search (follow-up) | | `context` | object | No | Framework/language/runtime/package manager constraints | | `external_session_id` | string | No | Publisher session ID for attribution and matching | | `external_user_id_hash` | string | No | Hashed publisher user ID | | `hashed_email` | string | No | SHA-256 of the normalized user email (canonical; legacy `email_hash` still accepted) | | `metadata` | object | No | Non-sensitive publisher metadata stored with the search | **Example:** ``` User: "I need a database for my project." Agent: gravity_index_search({ query: "database" }) → "I recommend Neon — serverless Postgres with a free tier. 1. npm install @neondatabase/serverless 2. Create lib/db.ts 3. Open the tracked setup link, get DATABASE_URL, and paste it back so I can finish" ``` ### `gravity_index_browse` Browse the catalog by category or keyword. | Parameter | Type | Required | Description | | - | - | - | - | | `category` | string | No | Filter by category | | `q` | string | No | Search keyword | ### `gravity_index_get_service` Fetch the full detail for a single service by slug. | Parameter | Type | Required | Description | | - | - | - | - | | `slug` | string | Yes | e.g. `"supabase"` | ### `gravity_index_list_categories` Enumerate every category with the service count. Takes no arguments. ### `gravity_index_report_integration` Record a completed integration when the user has adopted the recommended service. Accepts the `search_id` from a prior search and the slug of the service that was actually integrated. | Parameter | Type | Required | Description | | - | - | - | - | | `search_id` | string | Yes | From a previous `gravity_index_search` | | `integrated_slug` | string | Yes | e.g. `"stripe"` | ## Zero dependencies The npm server is a single Node 18+ `.mjs` file with no runtime deps. The Python server is stdlib-only. # Product search Source: https://docs.trygravity.com/gravity-index/products POST https://index.trygravity.com/shop/search Find consumer products a user can buy (shoes, cookware, electronics), not developer tools The Index has two verticals behind one publisher key: | The user wants… | Call | You get back | | - | - | - | | a developer service ("a Postgres with a free tier", "email API for Node") | [`POST /search`](/gravity-index/search) | **one** recommendation with reasoning 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 with price, image, and a tracked buy link | Your agent decides which one to call from the user's intent. A simple rule that works: if the answer is software or a service the user signs up for, use `/search`; if it would be shipped to a doorstep, use `/shop/search`. Both accept the same identity/attribution fields, so the plumbing is shared. ## Authentication Same key as every other Index endpoint: your publisher API key as the `X-API-Key` header or as `platform_api_key` in the body. Any production-approved publisher key works; there is no separate Index or shopping entitlement. Requests without a key get `401`; blocked publishers get `403`. ## Request Natural-language description of the product (up to 1000 chars). Matched with full-text search across name, brand, category and description, so concrete nouns beat vibes: "wide toe box running shoes" outperforms "something comfy for my feet". Required unless `context.messages` is sent. Optional hints from the conversation. `context.style` (alias `context.vibe`) is a free-text string that adds a soft ranking boost, e.g. `"neutral cushioned daily trainer"` or `"minimalist Scandinavian"`. `context.messages` (`[{role, content}]`) lets the Index work out the product need from the conversation itself when there is no explicit `query`; the response's `query` field shows what it inferred. Other keys are stored with the search and are ignored today. 1–25. Ask for what you will actually show; each result mints a tracked click link. Run the retrieved set through the LLM reranker, which keeps only products that fit the ask and may return nothing. `false` returns raw keyword ranking with no LLM on the hot path. ### Structured filters Filters are applied **before** ranking, so they are hard constraints, not preferences. Only pass them when the user stated them. Inclusive lower bound on the current (sale) price, in `currency`. Inclusive upper bound on the current (sale) price, in `currency`. ISO 4217 code. Price filters and returned prices are in this currency. The catalog is predominantly USD today. Up to 20 brand names, matched case-insensitively (`"Nike"` and `"nike"` are the same). Use it when the user names a brand; leave it out for "any brand". Up to 20 merchant names (`"Nike"`, `"Walmart"`, `"Lululemon"`), matched case-insensitively. Use it when the user wants to buy from a specific store. Only return products currently discounted below their regular price. Up to 10 colour words (`"black"`, `"navy"`). Listings are product-level, so this matches the colour named in the product title. Up to 10 sizes as the merchant lists them (`"10"`, `"M"`, `"32x34"`). Listings that name a different size are dropped; listings that name no size are kept, since the size may be picked at checkout. `men`, `women` or `kids`. Up to 10 of: `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`. Anything else is a `422`. Region is implied by the merchant catalog (US retailers today); there is no region filter. ### Identity and attribution (optional) Identical to `/search`. Send them if you want per-user attribution and conversion reporting. Your identifier for this conversation, up to 200 characters on this endpoint. Your stable identifier for this user. SHA-256 of the user's lowercased, trimmed email. Free-form key/values stored with the search (surface, locale, A/B arm…). Never put secrets or raw PII here. ## Response Always `"consumer"` on this endpoint. Use it to branch your rendering if you funnel both endpoints through one handler. Response contract version, currently `"2026-09-09"`. Unique ID for this search. Log it; it is the join key for click and conversion reporting. The product ask that was searched: your `query`, or what the Index inferred from `context.messages`. `products[0]`, or `null` when nothing fits. One sentence on why the top product was picked, or why nothing was. Ranked best-first. Empty when nothing in stock matches the query **and** every filter; loosen a filter or rephrase before telling the user there is nothing. Stable catalog ID. Merchant's product title, verbatim. Who sells and ships it. Merchant taxonomy path, e.g. `Clothing > Shoes > Mens Shoes > Mens Running Shoes`. `null` for live Shopify listings. Truncated to \~400 words. Current price the user will pay. List price. Equals `price` when not on sale; `null` when the merchant does not publish one. Product image. Always present; products without one are excluded. Tracked link to the merchant's product page. **This is the only URL you should show the user.** It redirects to the merchant, records the click against `search_id`, and unfurls as a product card (image, price, merchant) in iMessage, Slack and WhatsApp. Click ID embedded in `click_url`; useful if you report conversions yourself. Machine-payable destination when the merchant accepts agent checkout (today: every Shopify storefront, via [UCP](https://ucp.dev)). `null` for listings that only convert through `click_url`. See [Buying on the user's behalf](#buying-on-the-user’s-behalf). `"ucp"`. The merchant's own UCP MCP endpoint (`https://{store}/api/ucp/mcp`). Your agent calls it directly; Gravity is not in the payment path. The merchant's browser checkout for this variant, for handing the user off when the agent will not complete the purchase itself. The ID to put in `line_items[].item.id`, e.g. `gid://shopify/ProductVariant/32262292013136`. A hosted UCP agent profile you can pass as `meta.ucp-agent.profile` if you do not host your own. Pass through verbatim as the `attribution` argument of `create_checkout`. It carries the `grclid` onto the merchant's order, the same slot Google uses for `gclid`. `products.length`. Only non-empty when `products` is empty: up to 5 plain web listings for the same ask so the agent still has something to show. Each has `name`, `brand`, `price`, `currency`, `description`, `image_url`, `url` and `site`. `price` and `currency` are `null` unless the page states both; never invent one. These carry **no** tracked link and **no** `checkout`, so link `url` directly, say they are from the web rather than the catalog, and do not offer to buy them. Product or page title. Listed price in `currency`, or `null`. ISO 4217, only when the page states it. One sentence. Verified raster image, or `null`. The page itself. Not tracked. Hostname without `www.`, e.g. `chewy.com`. How the list was ordered. `"llm"`: the reranker kept only genuine fits, best relevance tier first, and inside a tier the listing that pays your platform more. `"retrieval"`: raw keyword order (`rerank: false`, or the reranker was unavailable). Informational. What the agent could filter on next, observed across the retrieved set: `brands`, `merchants`, `categories` (top 10 each) and `price_range` (`{min, max}` or `null`). There is no per-result `reasoning` here: products are ranked, not reasoned about one at a time. ```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": ["Clothing > Shoes > Womens Shoes"], "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 ### Natural-language query with a price cap ```bash cURL theme={null} curl -X POST https://index.trygravity.com/shop/search \ -H "X-API-Key: $GRAVITY_PUBLISHER_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"] ``` Example response, trimmed to one product: ```json theme={null} { "api_version": "2026-09-09", "vertical": "consumer", "search_id": "885eb1e4-ad46-417a-ae3f-7259291132e6", "query": "running shoes for wide feet", "reasoning": "Fdrone Women s Wide Toe Box Mesh Shoes from Walmart is the closest match.", "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 } ], "count": 3, "facets": { "brands": ["Fdrone", "Brooks", "New Balance"], "merchants": ["Walmart", "Zappos"], "categories": ["Clothing > Shoes > Womens Shoes"], "price_range": { "min": 17.0, "max": 139.95 } } } ``` `recommendation` (omitted above) repeats `products[0]`; `checkout` is `null` for catalog listings and populated for Shopify storefronts. The full two-product response, the zero-match response with `web_results`, and a `422` are in the response panel at the top of this page. ### 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: $GRAVITY_PUBLISHER_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 }' ``` In testing this returned three Nike Journey Run road-running variants at $95–$100 sold by Nike; without `brands` the same query returns marketplace listings first. ### Only deals ```json theme={null} { "query": "cast iron dutch oven", "on_sale_only": true, "merchants": ["Walmart"], "max_results": 5 } ``` ## Rendering results Show `name`, `brand`, `merchant`, `price` (with `regular_price` struck through when `on_sale`), `image_url`, and link the whole card to `click_url`. In chat surfaces the `click_url` alone is enough; it unfurls into the card. Do not rewrite or shorten `click_url`, and do not link to the merchant page directly; the redirect is what attributes the sale to your platform. ## Buying on the user's behalf When a product carries a non-null `checkout`, your agent can complete the purchase itself instead of handing the user a link. Call the merchant's `endpoint` (an MCP server) directly and pass the `attribution` object through unchanged; that is what attributes the order to your platform when no click happens. 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" } } ``` ```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 (`status: "requires_escalation"` until buyer, delivery and payment are supplied) and echoes `attribution` back. Continue with `update_checkout` and `complete_checkout` per the merchant's UCP profile at `https://{store}/.well-known/ucp`, or hand the user `checkout.continue_url` to finish in a browser. Attribution rules: * Always send `attribution` exactly as returned; do not mint your own click IDs. * Get the user's explicit consent before `complete_checkout`; it charges the payment method. * If the merchant rejects the call or `checkout` is `null`, use `click_url` as usual. ## Deciding between `/search` and `/shop/search` An agent-side router in three lines of prompt usually suffices. Over REST the two verticals are separate endpoints; over MCP there is one tool, `gravity_index_search`, and the consumer vertical is selected with `vertical: "consumer"`. ```text theme={null} If the user is asking for software, an API, hosting, a database, or anything they would sign up for → call POST /search (MCP: gravity_index_search). If they are asking for a physical product or something delivered to them → call POST /shop/search (MCP: gravity_index_search with vertical "consumer"). Otherwise, answer normally. ``` If your platform only serves one audience, wire only that endpoint; there is no penalty for ignoring the other vertical. ## Errors | Status | Meaning | | - | - | | `401` `platform_api_key required` | No key in header or body. | | `403` `valid platform_api_key required` | Key not recognised. | | `403` `publisher_not_approved` | Publisher is blocked. Same gate as production ad serving. | | `422` | Validation: neither `query` nor `context.messages` given, `query` over 1000 chars, `max_results` outside 1–25, more than 20 brands/merchants or 10 colours/sizes/categories, `external_session_id` over 200 chars. | # Search Source: https://docs.trygravity.com/gravity-index/search POST https://index.trygravity.com/search Find the best tool for a development task The core endpoint for **developer services**. An AI reasoning agent analyzes your query against the catalog and returns the single best match with an explanation of why it fits. Every matched response includes the **reasoning** and a **tracked click link** (when the recommended service has a destination URL) that the agent can hand to the user. Pass your `external_session_id` so the Index can use the conversation as private supporting context; see [Conversation context](/gravity-index/conversation-context). Looking for physical products (shoes, cookware, electronics)? That is a different vertical with a ranked-list response; use [`POST /shop/search`](/gravity-index/products). Sending a shopping query here returns no match. ## Authentication Send your publisher key as the `X-API-Key` header or as the `platform_api_key` body field. One of the two is required. Publisher API key. Alternative to the `platform_api_key` body field. ## Request What you need. Natural language — e.g. "serverless database with free tier" or "auth for Next.js with SSO" Pass a `search_id` from a previous response to ask a follow-up question. The Index maintains conversation context. Additional project context the reasoning agent should weigh, e.g. `{"framework": "nextjs", "language": "typescript", "constraints": ["free tier", "EU region"]}`. Free-form; anything the user already told you about their stack belongs here rather than stuffed into `query`. Publisher API key for attribution. Alternative to the `X-API-Key` header — send one or the other. Your stable chat/session ID. Send it by default: when Gravity has a summary of that conversation, Index uses it as private supporting context for the recommendation (see [Conversation context](/gravity-index/conversation-context)). Also used for attribution and payout matching. Never returned in the response. Hash of your user ID. Prefer this over sending a raw user identifier. Optional raw user ID. Gravity stores only a SHA-256 hash. SHA-256 of the user's normalized email (`email.strip().lower()`), when available. This is the canonical field and matches the [ad request](/ai-platforms/request-ads) convention. The legacy `email_hash` field is still accepted as an alias. Non-sensitive publisher metadata such as surface, workspace, plan, or placement. Stored with the search for attribution/debugging; not used in the LLM prompt. Optional private publisher ranking controls. Requires `platform_api_key`. Supported mode: `boost_cpa` — relevance-first ranking that gives a small boost to services with an active CPA campaign. Omit (or use `off`) for pure relevance. Economics are not returned in the response. ## Response Unique ID for this search. Pass it back for follow-ups. The recommended service. Service name URL-safe identifier e.g. Database, Auth, AI What it does Service website Documentation link Why this tool is the best fit for your specific query. Natural language explanation. Optional structured setup details for the recommended service. Present when the catalog has them; agents may ignore this and simply present the recommendation. One-line install description Ordered install steps with commands, files, and user actions Environment variables needed Context-specific configuration notes Explicit handoff instructions for credentials the human user must fetch. Tracked setup link the user opens Environment variables the user should paste back User-facing instruction to show verbatim Agent-facing instruction for pausing/resuming setup Gravity click ID for attribution Tracked short link the user visits to sign up with the recommended service. Gravity Index click ID minted for the tracked click link. ## Examples ### Basic search ```bash theme={null} curl -X POST https://index.trygravity.com/search \ -H "Content-Type: application/json" \ -H "X-API-Key: $GRAVITY_PUBLISHER_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"} }' ``` ```json theme={null} { "search_id": "238c2e03-347b-438e-8b52-f9cacee31ebe", "recommendation": { "name": "Neon", "slug": "neon", "category": "Database", "description": "Serverless Postgres with branching, autoscaling, and bottomless storage.", "website_url": "https://neon.tech" }, "reasoning": "Neon is the best fit because it's serverless Postgres with a generous free tier, supports edge functions, and has database branching 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 → Connection Details"} ], "env_vars": ["DATABASE_URL"] }, "credential_request": { "setup_url": "https://index.trygravity.com/go/xKHR2xqP9z", "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.", "grclid": "xKHR2xqP9z" }, "click_url": "https://index.trygravity.com/go/xKHR2xqP9z", "grclid": "xKHR2xqP9z" } ``` ### Follow-up question ```bash theme={null} curl -X POST https://index.trygravity.com/search \ -H "Content-Type: application/json" \ -H "X-API-Key: $GRAVITY_PUBLISHER_KEY" \ -d '{ "search_id": "238c2e03-347b-438e-8b52-f9cacee31ebe", "query": "Does it support database branching for preview deploys?" }' ``` The Index remembers the previous conversation and answers in context. # Core concepts Source: https://docs.trygravity.com/introduction/concepts The vocabulary used across the Gravity docs. ## Roles An AI app that shows ads — chat UIs, coding assistants, search products, consumer AI. Earns revenue share on every ad served. A brand running ad campaigns. Sets budgets, creative, and bidding. Pays for impressions. ## Ad matching **Contextual** — Ads are matched to the content of the conversation. The engine embeds recent messages, retrieves relevant campaigns, runs an auction, and generates creative tuned to the conversation. ## Billing | Model | How it works | | - | - | | **CPM** | Cost per 1,000 impressions. Gravity's public billing model. | ## Pixels The Gravity pixel is a lightweight first-party script with two install surfaces: Installed on your site or inside your in-app browser. Links clicks that open inside a WebView back to the originating impression, so attribution survives the in-app → landing-page transition. Installed on the advertiser's site. Fires a conversion event when a user completes an action (purchase, signup). Powers conversion reporting. Both are first-party. They do not share data across unrelated sites. ## Placements A **placement** is a named slot in your UI where an ad can appear. Placements are a first-class concept in Gravity because they're how we understand where ads are rendering, how they perform per-slot, and how we split traffic for testing. ### Why they matter * **Per-slot reporting.** Dashboard shows impressions, CTR, and revenue per `placement_id`. You learn which spots in your UI carry their weight and which don't. * **Scoped experiments.** We can run experiments targeted to a specific placement — test a new treatment in your right-rail without touching your below-response slot. * **Auto-optimization.** The engine learns which ad formats work best per placement over time and leans into the winners. ### Standard placement types Standard keys cover most UI layouts: `above_response`, `below_response`, `inline_response`, `left_response`, `right_response`, plus page-level options (`search_result`, `top_page`, `bottom_page`, `left_page`, `right_page`, `center_page`). Every ad request names its placements and an identifier for each slot. Every impression is attributed to one. ### How to set them up Create placement slots in your Gravity dashboard under **AI platforms → Placements**, then send each slot's stable `placement_id` in your ad request. The `placement_id` correlates a request slot to a registered placement and ties dashboard analytics back to the spot in your UI — it doesn't create the placement, and unregistered ids are filtered out of the auction. See the [Placements guide](/ai-platforms/placements) for full details. ## Ad designs Gravity manages each placement's design. Official SDK components apply the design and tracking automatically. ## Experiments Gravity can test placement designs without publisher code changes. See [Experiments](/ai-platforms/experiments). ## Sessions A **session** is a conversation. The `sessionId` you pass to `gravityContext()` groups related ad requests together for frequency capping, experiment bucketing, and reporting. Usually one chat thread = one session. ## Data quality The richer the signal you send with each ad request, the better Gravity can match, report, and attribute. Most AI platforms leave revenue on the table by sending the minimum. High-value signals, in order of impact: * **Hashed email.** If your app knows the user's email, passing a SHA-256 hash on the ad request unlocks **view-through attribution** — we can tie a later purchase or signup on an advertiser's site back to an impression that didn't get clicked directly. This is typically the single biggest lever on reported revenue. * **Stable user identifier.** Even without email, a consistent `userId` per user lets us dedupe requests, cap frequency intelligently, and build smarter matching over time. * **Rich conversation context.** Passing the recent messages (not just the latest one) gives the engine better signal for contextual matching. * **Device + locale.** Auto-included when you use the SDK's `gravityContext()`, but worth confirming if you're calling the API directly — country, device type, and timezone all feed into relevance. Hashed email specifically is the difference between "we only count the direct clicks" and "we also count the users who saw the ad, remembered the brand, and came back later." Worth wiring in when you can. ## Claim flow When a platform provisions a Gravity account on behalf of its user (see: `Partner API`, currently not public), the end user **claims** their account by signing in and linking it to their Gravity profile. Claim unlocks dashboard access, payout configuration, and full control. # How it works Source: https://docs.trygravity.com/introduction/how-it-works The request flow, the auction, and what makes matching contextual. ## The request flow ```mermaid theme={null} sequenceDiagram participant Client as Your client
(browser / app) participant Server as Your server participant LLM as LLM provider participant Gravity as Gravity Engine Client->>Server: messages + gravity_context par Server->>LLM: chat completion LLM-->>Server: streamed tokens and Server->>Gravity: POST /api/v1/ad Gravity-->>Server: matched ad end Server-->>Client: SSE stream (tokens + ads) ``` 1. The user sends a message. Your client fires the LLM request **and** calls `gravityContext()` to capture device + session info. 2. Your server receives both and fires `gravity.getAds()` in parallel with the LLM call. The ad request never blocks streaming. 3. Gravity's engine matches the conversation against active campaigns, runs an auction, and returns the winning ad. 4. Your stream appends the ad as a final chunk. Your client shows it. Everything happens in a few hundred milliseconds end-to-end. Since it runs in parallel with your LLM call, user-perceived latency doesn't move. ## Contextual matching When a user asks *"how do I deploy a serverless Postgres database?"*, the engine looks at the recent conversation and returns an ad whose campaign targets that topic — say, a managed Postgres provider. The ad copy is generated on the fly to fit the conversation, so the user sees something relevant and specific rather than a generic banner. There's no persistent user profile driving this match. It lives and dies with the conversation. ## What gets paid out You get paid when the ad becomes visible to the user. CPM is the public billing model on Gravity today. ## What's next Build the integration end-to-end. Glossary of the terms used throughout the docs. # Monetize your AI app with contextual ads Source: https://docs.trygravity.com/introduction/overview Gravity is the advertising layer for AI applications — ship ads that match the conversation, earn per impression. ## What Gravity is Gravity is ad infrastructure built for AI platforms. You add a few lines of code; we match relevant ads to the conversation your users are having, handle the auction, render the ad, and pay you for impressions. The ad request runs in parallel with your model call, so your user-perceived latency doesn't move. From zero to your first ad in under five minutes. The request flow, the auction, and what makes matching contextual. ## Who this is for Running an AI chat, coding assistant, search app, or consumer AI product. Show ads, earn revenue. Brands running campaigns across the Gravity network. Target by conversation context, pay per impression. ## Two ways to integrate **SDK — recommended.** A few lines in your chat component (client) and your request handler (server). The SDK handles context preparation, impression tracking, and — importantly — [experiments](/ai-platforms/experiments), which let us test different ad creative and formats to find what converts best for your traffic. You don't have to change anything to benefit. **API (fully supported).** Hit `POST https://server.trygravity.com/api/v1/ad` directly. You own the rendering. The SDK's value-adds (experiments, pre-built React components, automatic tracking) are yours to build, but nothing stops you from using the API. It's the same engine. | | SDK (`@gravity-ai/api` + `@gravity-ai/react`) | API (direct) | | - | - | - | | Request ads | ✅ | ✅ | | Show ads | ✅ pre-built renderers | ✅ your code | | Impression tracking | ✅ automatic | You fire `impUrl` when ad becomes visible | | Experiments (A/B) | ✅ automatic | Contact Gravity for custom-renderer support | | Design updates | ✅ automatic | You maintain your renderer | Both paths are fully supported and hit the same engine. The SDK is the fastest path to production if you're on Node / TypeScript / Python / React; the API is the right call if you're on an unusual stack or want full rendering control. ## Supported surfaces AI chat, coding assistants and IDEs, AI consumer apps, AI-powered search. Any surface where a user has a conversation with a model. ## What to read next Integration guide, SDK docs, pixel, going live, payouts. Campaign setup, pixel, and conversion tracking. Full HTTP API surface. Authentication, endpoints, response codes. # Android SDK Source: https://docs.trygravity.com/sdks/android Kotlin SDK for integrating Gravity contextual ads into Android apps. ## Installation Add the dependency to your module's `build.gradle.kts`: ```kotlin theme={null} dependencies { implementation("ai.trygravity:gravity-sdk:1.0.0") } ``` The SDK requires `INTERNET` permission (declared in its manifest — no action needed). ## How the pieces fit together Create a `Gravity` instance with your API key, a `Context`, and `production = true`. Call `gravity.getAds(...)` with the conversation messages, session ID, and placements. The SDK auto-collects device signals (UA, OS, screen, timezone). Run it in parallel with your LLM call using coroutines. Use the built-in `GravityAd` Compose composable, `GravityAdView` (Views), or render the ad data yourself. Impression tracking fires automatically. ## Quick start ```kotlin theme={null} import ai.trygravity.sdk.Gravity import ai.trygravity.sdk.models.* // 1. Initialize (once, e.g. in your ViewModel or Application) val gravity = Gravity(context, apiKey = "pub_...", production = true) // 2. Fetch ads in parallel with your LLM 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 LLM response... val result = adDeferred.await() // 3. Render result.ads.firstOrNull()?.let { ad -> // Use GravityAd composable or 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", timeoutSeconds: Long = 4L, production: Boolean = false, relevancy: Double = 0.2, excludedTopics: List? = null, ) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `context` | `Context` | — | Android context for device signal collection | | `apiKey` | `String` | `GRAVITY_API_KEY` env var | Your Gravity publisher API key | | `timeoutSeconds` | `Long` | `4` | Abort after this many seconds | | `production` | `Boolean` | `false` | `false` returns test ads (no billing) | | `relevancy` | `Double` | `0.2` | Minimum relevancy threshold 0.0–1.0 | | `excludedTopics` | `List?` | `null` | Topics to exclude from matching | ## `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, ): AdResult ``` The SDK auto-collects device signals (User-Agent, OS, model, screen size, timezone, locale, device type) and sends them with the request. Your dashboard's Device and Geography breakdowns work automatically. `getAds()` never throws. On any failure, it returns `AdResult.EMPTY` (empty ads list). Safe to fire-and-forget. ## Placements ```kotlin theme={null} Placement.belowResponse() // "below_response" Placement.inlineResponse() // "inline_response" Placement.aboveResponse() // "above_response" // Custom Placement("right_response", "sidebar") ``` ## Jetpack Compose — render ads ```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` automatically handles the Gravity-managed placement design, safe fallback, impression and click tracking, required ad disclosure, images, and server-requested feedback controls. | Parameter | Type | Description | | - | - | - | | `ad` | `AdResponse` | The ad object from `getAds()` | | `gravity` | `Gravity` | Client instance for tracking | | `sessionId` | `String` | Must match the ad request | | `showFeedback` | `Boolean` | Allow feedback controls. Default `true`. The prompt renders only when this is `true` **and** the server marked the ad with `feedbackPrompt: true` (selective — borderline-relevance / tight-auction ads). One tap submits and swaps to "Thanks for the feedback"; feedback taps never trigger the ad click. | ## Android Views For XML-based layouts, use `GravityAdView`: ```kotlin theme={null} import ai.trygravity.sdk.views.GravityAdView val adView = GravityAdView( context = this, ad = ad, gravity = gravity, sessionId = sessionId, ) container.addView(adView) ``` ## Custom rendering If you render ads with your own UI, fire tracking manually: ```kotlin theme={null} // Impression — fire once when the ad becomes visible gravity.trackImpression(ad) // Click — always use clickUrl, not url ad.clickUrl?.let { url -> startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } ``` ## PII hashing Hash email and phone for attribution without sending raw values: ```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 ```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"), ) ``` ## Full example — parallel with LLM 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 = "pub_...", production = true) val ads = MutableStateFlow>(emptyList()) val response = MutableStateFlow("") fun send(messages: List, sessionId: String, userId: String) { viewModelScope.launch { // Fire ad request in parallel val adDeferred = async { gravity.getAds( messages = messages, sessionId = sessionId, userId = userId, placements = listOf(Placement.belowResponse()), ) } // Stream LLM response streamLLM(messages).collect { chunk -> response.update { it + chunk } } // Append ads ads.value = adDeferred.await().ads } } } ``` ## FAQ No. Use `async` to fire `getAds()` in parallel with your LLM stream. It never throws — failures return an empty list. No. The SDK does not access Google Advertising ID. If you have user consent, pass it as `extraDeviceFields = mapOf("ifa" to gaid)`. Android 7.0+ (API 24+). OkHttp for networking and Kotlin Coroutines. Compose dependencies are `compileOnly` — only pulled in if your app already uses Compose. # iOS SDK Source: https://docs.trygravity.com/sdks/ios Swift SDK for integrating Gravity contextual ads into iOS, macOS, tvOS, and watchOS apps. ## Installation Add the package in Xcode via **File → Add Package Dependencies**: ``` https://github.com/Try-Gravity/gravity-swift-sdk ``` Or in your `Package.swift`: ```swift theme={null} dependencies: [ .package(url: "https://github.com/Try-Gravity/gravity-swift-sdk", from: "1.0.0"), ] ``` ## How the pieces fit together Create a `Gravity` instance with your API key and `production: true`. Call `gravity.getAds(...)` with the conversation messages, session ID, and placements. The SDK auto-collects device signals (UA, OS, screen, timezone). Run it in parallel with your LLM call. Use the built-in `GravityAdView` (SwiftUI) or render the ad data yourself. Impression tracking fires automatically on appear. ## Quick start ```swift theme={null} import GravitySDK // 1. Initialize (once, e.g. in your App or ViewModel) let gravity = Gravity(apiKey: "pub_...", production: true) // 2. Fetch ads in parallel with your LLM 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 LLM response... let result = await adResult // 3. Render if let ad = result.ads.first { GravityAdView(ad: ad, gravity: gravity, sessionId: "sess_abc123") } ``` ## Constructor ```swift theme={null} Gravity( apiKey: String? = nil, // defaults to GRAVITY_API_KEY env var apiUrl: String = "https://server.trygravity.ai/api/v1/ad", timeoutSeconds: TimeInterval = 4.0, production: Bool = false, // false = test ads, no billing relevancy: Double = 0.2, // 0.0–1.0 contextual match threshold excludedTopics: [String]? = nil ) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `apiKey` | `String?` | `GRAVITY_API_KEY` env var | Your Gravity publisher API key | | `timeoutSeconds` | `TimeInterval` | `4.0` | Abort after this many seconds | | `production` | `Bool` | `false` | `false` returns test ads (no billing) | | `relevancy` | `Double` | `0.2` | Minimum relevancy threshold 0.0–1.0 | | `excludedTopics` | `[String]?` | `nil` | Topics to exclude from matching | ## `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 ) -> AdResult ``` The SDK auto-collects device signals (User-Agent, OS, screen size, timezone, locale, device type) and sends them with the request. For mobile apps, this means your dashboard's Device and Geography breakdowns work automatically — no manual `device` forwarding needed. `getAds()` never throws. On any failure, it returns `AdResult.empty` (empty ads array). Safe to fire-and-forget. ## Placements ```swift theme={null} // Convenience constructors Placement.belowResponse() // "below_response" Placement.inlineResponse() // "inline_response" Placement.aboveResponse() // "above_response" // Custom Placement(placement: "right_response", placementId: "sidebar") ``` ## SwiftUI — render ads ```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` automatically handles the Gravity-managed placement design, safe fallback, impression and click tracking, required ad disclosure, and server-requested feedback controls. | Parameter | Type | Description | | - | - | - | | `ad` | `AdResponse` | The ad object from `getAds()` | | `gravity` | `Gravity` | Client instance for tracking | | `sessionId` | `String` | Must match the ad request | | `showFeedback` | `Bool` | Allow feedback controls. Default `true`. The prompt renders only when this is `true` **and** the server marked the ad with `feedbackPrompt: true` (selective — borderline-relevance / tight-auction ads). One tap submits and swaps to "Thanks for the feedback"; feedback taps never trigger the ad click. | ## Custom rendering If you render ads with your own UI, fire tracking manually: ```swift theme={null} // Impression — fire once when the ad becomes visible gravity.trackImpression(ad) // Click — always use clickUrl, not url if let clickUrl = ad.clickUrl, let url = URL(string: clickUrl) { UIApplication.shared.open(url) } ``` ## PII hashing Hash email and phone for attribution without sending raw values: ```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 ```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"] ) ``` ## Full example — parallel with LLM stream ```swift theme={null} import GravitySDK class ChatViewModel: ObservableObject { let gravity = Gravity(apiKey: "pub_...", production: true) @Published var ads: [AdResponse] = [] @Published var response = "" func send(messages: [Message], sessionId: String, userId: String) async { // Fire ad request in parallel async let adResult = gravity.getAds( messages: messages, sessionId: sessionId, userId: userId, placements: [.belowResponse()] ) // Stream LLM response for await chunk in streamLLM(messages) { await MainActor.run { response += chunk } } // Append ads let result = await adResult await MainActor.run { ads = result.ads } } } ``` ## FAQ No. Use `async let` to fire `getAds()` in parallel with your LLM call. It never throws, so failures are silent. No. The SDK does not access the Advertising Identifier. If you have IDFA consent via ATT, pass it as `extraDeviceFields: ["ifa": idfa]`. iOS 15+, macOS 12+, tvOS 15+, watchOS 8+. No. It uses only Foundation, CryptoKit, and URLSession — all built into the platform. # JavaScript and React SDKs Source: https://docs.trygravity.com/sdks/javascript Render Gravity ads in React or request them from JavaScript and TypeScript. ## React SDK ```bash theme={null} npm install @gravity-ai/react ``` Pass the ad returned by Gravity to `GravityAd`: ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; export function ChatResponse({ response, ads }) { return (

{response}

{ads[0] && }
); } ``` `GravityAd` automatically handles the placement design, fallback, impression and click tracking, disclosure, and feedback controls. Pass the API key used for the ad request to enable feedback submissions: ```tsx theme={null} ``` `apiKey` is visible in the browser and used for feedback submissions. Pass it down from your server with the ad response rather than referencing a server-only environment variable in client code — bundlers either drop the value or inline your server secret into the bundle. ## JavaScript and TypeScript SDK ```bash theme={null} npm install @gravity-ai/api ``` Set `GRAVITY_API_KEY` in your server environment. ### Prepare context In your client application, prepare the session and user context: ```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 }), }); ``` Forward the complete `gravity_context` to your server. ### Request ads Request ads in parallel with your LLM 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()` returns `{ ads: [] }` on no-fill or failure, so render the slot conditionally. Keep one stable `sessionId` per conversation. | Option | Default | Description | | - | - | - | | `apiKey` | `GRAVITY_API_KEY` | Publisher API key. | | `production` | `false` | Set `true` for live ads. | | `timeoutMs` | `4000` | Request timeout in milliseconds. | | `relevancy` | `0.2` | Minimum contextual relevance. | ## Custom rendering Use `GravityAd` unless you need a fully custom renderer. Custom renderers must open `ad.clickUrl`, request `ad.impUrl` once when the ad becomes visible, and show a clear ad disclosure. # Python SDK Source: https://docs.trygravity.com/sdks/python Python SDK for requesting contextual ads from the Gravity API. ## Installation ```bash theme={null} pip install gravity-sdk ``` Set `GRAVITY_API_KEY` in your server environment. Only dependency is [httpx](https://www.python-httpx.org/). ## Quick start Add a few lines to your existing streaming chat endpoint. The ad request runs in parallel with your LLM call — zero added latency. ```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") ``` Works with **FastAPI**, **Starlette**, **Django**, and **Flask**. ## Constructor ```python theme={null} Gravity(*, api_key=None, api_url=None, timeout=4.0, production=False, relevancy=0.2) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `api_key` | `str` | `GRAVITY_API_KEY` env var | Your Gravity API key | | `api_url` | `str` | Production URL | Gravity API endpoint | | `timeout` | `float` | `4.0` | Request timeout in seconds | | `production` | `bool` | `False` | `False` returns test ads (no billing) | | `relevancy` | `float` | `0.2` | Minimum relevancy threshold, 0.0-1.0. Lower = more ads with weaker contextual matches | The client reuses its HTTP connection pool across calls. Use `async with Gravity() as g:` or call `await gravity.close()` for explicit cleanup. ## `get_ads()` ```python theme={null} await gravity.get_ads(request, messages, placements, *, production=None, relevancy=None) ``` | Parameter | Type | Default | Description | | - | - | - | - | | `request` | framework request | — | Your server's request object (FastAPI, Django, Flask, etc.) | | `messages` | `list[dict]` | — | Conversation messages `[{"role": ..., "content": ...}]` | | `placements` | `list[dict]` | — | Ad placements, e.g. `[{"placement": "chat", "placement_id": "main"}]` | | `production` | `bool \| None` | `None` | Override the constructor-level `production` flag for this call | | `relevancy` | `float \| None` | `None` | Override the constructor-level `relevancy` threshold for this call | `get_ads()` reads `gravity_context` (session, user, and **device**) from the posted request body and adds the end-user IP from the request headers. It does **not** invent a device — your browser client must call `gravityContext()` and post the result, and your backend must leave it in the body it forwards. Forward the client's `device` — `device.ip` and `device.ua` are **required**. If the request reaching Gravity has no end-user `ua`/`ip` — e.g. a pure server-to-server call that drops the client context — it is rejected with HTTP `400` and no ad is served. See [Device signals](/ai-platforms/request-ads#device-signals). ## Return types ```python theme={null} @dataclass class AdResult: ads: list[AdResponse] # Parsed ad objects status: int # 200, 204, 0 (error) elapsed_ms: str # e.g. "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 campaign_id: str | None # Experiment / composition fields (present when an experiment is active) variant: str | None experiment_id: str | None composition_id: str | None renderer_key: str | None composition_mode: str | None composition: dict | None ``` Both have `.to_dict()` methods that serialize to the camelCase JSON shape renderers expect. ## Message handling The SDK sends the last 2 conversational messages to the Gravity API for contextual ad matching. Only messages with recognized roles are included: * `user`, `assistant`, `system`, `developer`, `model` (Gemini's alias for `assistant`) Messages with other roles (e.g. `tool`, `function`, `ipython`) are filtered out — they typically contain structured data rather than natural language. `gravity.get_ads()` never raises. On any failure, it returns `AdResult(ads=[])`. Safe to fire-and-forget in your stream. ## PII hashing The SDK provides `hash_pii()` for SHA-256 hashing of emails and phone numbers, matching the normalization used by the Gravity publisher pixel and the advertiser-side conversion pipeline. ```python theme={null} from gravity_sdk import hash_pii hashed = hash_pii(email=user.email, phone=user.phone) # Include hashed PII in the gravity_context.user sent from your client: # { "gravity_context": { "user": { "id": "u123", "email_hash": "...", "hashed_phone": "..." } } } ``` Normalization rules: * **Email**: `strip().lower()` * **Phone**: digits only (e.g. `"+1 (555) 123-4567"` → `"15551234567"`) Returns a `HashedIdentity` dict with only the keys that successfully hashed (`email_hash`, `hashed_phone`), so it's safe to spread into a user dict. `email_hash` is the canonical email field — it matches the `email_hash` used by the [Index `/search` API](/gravity-index/search). ## Experiments Experiments are managed server-side. Return the ad normally and render it with the standard SDK component; no experiment-specific branching or tracking code is required. # React Native SDK Source: https://docs.trygravity.com/sdks/react-native Render Gravity ads natively in React Native apps. ## Install ```bash theme={null} npm install @gravity-ai/react-native ``` `react` (>=18.2) and `react-native` (>=0.76) are peer dependencies. The package has no DOM or `react-dom` dependency. ## Request ads Request ads from your server with the [JavaScript SDK](/sdks/javascript). Spread `GRAVITY_RN_SPEC_OPTIONS` into the request so Gravity returns placement designs the native renderer can paint: ```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, }); ``` ## Render ads Pass the ad returned by Gravity to `GravityAd`: ```tsx theme={null} import { GravityAd, GravityThemeProvider } from '@gravity-ai/react-native'; export function ChatResponse({ response, ads }) { return ( {response} {ads[0] && } ); } ``` `GravityAd` automatically handles the placement design (including [image ads](/engine/contextual-ads#image-ads) from 0.2.0), fallback, impression and click tracking, disclosure, and light and dark themes. The impression fires once when at least 50% of the ad is visible. ## Visibility in lists In a virtualized list (`FlatList`, `SectionList`), pass viewability from `onViewableItemsChanged` so the impression fires exactly when the list reports the ad visible: ```tsx theme={null} ``` In a plain `ScrollView`, no wiring is required — visibility is measured automatically. Publishers who drive visibility from their own `onScroll` handler can use the `measureVisibility` callback returned by `useAdTracking`. ## Theming `GravityThemeProvider` follows the device color scheme by default. Override semantic tokens as needed: ```tsx theme={null} ``` # Server-rendered ads Source: https://docs.trygravity.com/sdks/server-rendered-ads Render Gravity-managed ad designs with the official SDKs. Gravity can update a placement's ad design without requiring a publisher code change or SDK upgrade. Publishers only install the SDK and mount the appropriate ad component. ## React ```bash theme={null} npm install @gravity-ai/react ``` Render the ad returned by your Gravity API request: ```tsx theme={null} import { GravityAd } from '@gravity-ai/react'; export function ChatResponse({ response, ads }) { return (

{response}

{ads[0] && }
); } ``` See the [JavaScript and React SDK guide](/sdks/javascript) for request setup. ## React Native ```bash theme={null} npm install @gravity-ai/react-native ``` Include `GRAVITY_RN_SPEC_OPTIONS` in your ad request, then render the returned ad: ```tsx theme={null} import { GravityAd, GravityThemeProvider } from '@gravity-ai/react-native'; export function ChatResponse({ response, ads }) { return ( {response} {ads[0] && } ); } ``` See the [React Native SDK guide](/sdks/react-native) for request setup and list visibility wiring. ## iOS Use `SpecAdView` with the ad returned by `getAds()`: ```swift theme={null} SpecAdView( ad: ad, gravity: gravity, sessionId: sessionId ) ``` See the [iOS SDK guide](/sdks/ios) for installation and request setup. ## Android Use `SpecAd` with the ad returned by `getAds()`: ```kotlin theme={null} SpecAd( ad = ad, gravity = gravity, sessionId = sessionId, ) ``` See the [Android SDK guide](/sdks/android) for installation and request setup. ## Handled automatically The official SDKs handle: * compatible server-managed placement designs * built-in fallback when no valid design is available * impression and click tracking * required ad disclosure * light and dark themes * server-requested feedback controls A custom design changes presentation only. Tracking, attribution, and billing use the same ad response URLs. ## Image ads When an ad has a product image, Gravity sends an image card layout with it: the image on one side, the copy on the other. The official SDKs render it with no code change, starting at `@gravity-ai/react` 1.3.0, `@gravity-ai/ui` 0.3.0 and `@gravity-ai/react-native` 0.2.0. Older versions keep showing the text card. Ads without an image always use the text card. To preview an image ad, send a test request with `testAdFormat: "image"` — see [Image ads](/engine/contextual-ads#image-ads). ## Custom placement design Contact [support@trygravity.com](mailto:support@trygravity.com) with your placement identifier and desired look. Gravity will configure and validate the design before it goes live. # Terminal / CLI SDK Source: https://docs.trygravity.com/sdks/terminal Integrate Gravity ads and tracking into Node.js, Bun, and Deno terminal applications using @gravity-ai/api. ## Installation ```bash theme={null} npm install @gravity-ai/api ``` Set `GRAVITY_API_KEY` in your server environment. ## How the pieces fit together `gravityPixel()` handles session and visitor tracking automatically. Initialize it once when your app starts. `gravityContext()` captures device signals and session info. Pass the pixel's visitor and session IDs to link tracking with ad requests. `gravity.getAds(req, messages, placements)` reads the context, adds the client IP, and returns matched ads. Runs in parallel with your LLM call. ## Initialize the pixel ```ts theme={null} import { gravityPixel } from '@gravity-ai/api'; const pixel = gravityPixel({ pixelId: 'YOUR_PIXEL_ID' }); await pixel.start(); ``` Find `YOUR_PIXEL_ID` in your dashboard under **Settings → Platform Settings**. The pixel automatically manages sessions, visitor IDs, and attribution — same as the browser pixel (`gr-pix.js`), but using local file storage instead of cookies. Session state is persisted and cleaned up automatically on process exit. | Parameter | Type | Default | Description | | - | - | - | - | | `pixelId` | `string` | — | **Required.** Your Gravity pixel ID. | | `appName` | `string` | `'terminal'` | Application label, used as page title in events. | | `eventEndpoint` | `string` | `'https://api.trygravity.ai/track/gr-events'` | Event endpoint URL. | | `sessionEndpoint` | `string` | `'https://api.trygravity.ai/track/gr-session'` | Session endpoint URL. | | `sessionTimeoutMinutes` | `number` | `30` | Session idle timeout in minutes. | | `attrMaxAgeDays` | `number` | `180` | Max age for persisted attribution in days. | | `sessionMaxPages` | `number` | `50` | Max entries in invocation history. | | `hashIdentityFields` | `boolean` | `true` | Hash identity fields with SHA-256 before sending. | | `storageDir` | `string` | `'~/.gravity'` | Custom storage directory for session and attribution data. | ## Prepare 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()` auto-detects terminal device signals (OS, timezone, locale). Required parameters: | Parameter | Type | Required | Description | | - | - | - | - | | `sessionId` | `string` | Yes | From `pixel.getSessionId()` | | `user.userId` | `string` | **Yes** | Stable per-user identifier | ## Fetch 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' }], ); ``` `gravity.getAds()` never throws. On any failure, it returns `{ ads: [] }` silently. Safe to fire-and-forget. ## Full example ```ts theme={null} import { gravityPixel, gravityContext, Gravity } from '@gravity-ai/api'; // 1. Initialize pixel const pixel = gravityPixel({ pixelId: 'YOUR_PIXEL_ID' }); await pixel.start(); // 2. Set up ad client const gravity = new Gravity({ production: true }); // 3. On each user interaction 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 No. `gravityPixel` ships inside `@gravity-ai/api`. Just `npm install @gravity-ai/api` and import it. Yes. It uses standard APIs and falls back gracefully when Node-specific modules aren't available. No. Fire `gravity.getAds()` in parallel with your LLM call. It never throws, so failures are silent. Set `GRAVITY_API_KEY` as an environment variable. The `Gravity` class reads it automatically. You can also pass it via `new Gravity({ apiKey: '...' })`.