Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.trygravity.ai/llms.txt

Use this file to discover all available pages before exploring further.

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' }
]);

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

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 },
});
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 as user.hashed_email and user.hashed_phone. Don’t send raw email or phone over the wire.

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.