Skip to main content

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.
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' }
]);
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
curl -X POST https://server.trygravity.ai/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.

What’s in the request

FieldRequiredDescription
messagesYesRecent conversation messages. Array of {role, content}. The engine uses the last few for contextual matching.
sessionIdYesYour session identifier. Used for frequency capping and experiment bucketing. Enforced by the engine. Free if the Gravity 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.
placementsYesArray of {placement, placement_id}. 1–10 per request.
user.userIdYes for JS SDK; Optional for Python SDKStable 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.phoneOptionalSee User data & hashing below — raw values are hashed by the SDK before they leave the client.
deviceYes (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 below).
excludedTopicsOptionalArray of topic strings to exclude (e.g. ["politics"]).
relevancyOptionalMinimum 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 below.
testAdOptionalDefaults 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.
FieldTypeRequiredDescription
uastringYesEnd-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.
ipstringYesEnd-user IP. Drives fraud detection and Geography. The SDK auto-fills this from the incoming request; direct HTTP callers must set it explicitly.
countrystringNo2-letter ISO country code. Optional override — Gravity also derives country from ip.
osstringNoOperating system (e.g. iOS, Android, Windows).
ifastringNoMobile advertising ID (IDFA / GAID), for app publishers.
idstringNoYour stable per-device identifier, for analytics/fraud.
Extra fields (timezone, locale, browser, device_model, …) are accepted and stored.
"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:
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 },
});
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) 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:
[
  {
    "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)

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

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:
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:
SettingWhat happensWhen 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.
const gravity = new Gravity({ production: true, relevancy: 0.35 });
Timeout. Default 3 seconds. Raise if your stack can tolerate it; lower if you’re strict on SLAs.
const gravity = new Gravity({ production: true, timeoutMs: 5000 });
Excluded topics. Per-request:
await gravity.getAds(req, messages, placements, {
  excludedTopics: ['politics', 'religion']
});

Next

Show ads

Render them in your UI.

API Reference

Full HTTP surface with all parameters.