Skip to main content

Installation

pip install gravity-sdk
Set GRAVITY_API_KEY in your server environment. Only dependency is httpx.

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

Gravity(*, api_key=None, api_url=None, timeout=3.0, production=False, relevancy=0.2)
ParameterTypeDefaultDescription
api_keystrGRAVITY_API_KEY env varYour Gravity API key
api_urlstrProduction URLGravity API endpoint
timeoutfloat3.0Request timeout in seconds
productionboolFalseFalse returns test ads (no billing)
relevancyfloat0.2Minimum 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()

await gravity.get_ads(request, messages, placements, *, production=None, relevancy=None)
ParameterTypeDefaultDescription
requestframework requestYour server’s request object (FastAPI, Django, Flask, etc.)
messageslist[dict]Conversation messages [{"role": ..., "content": ...}]
placementslist[dict]Ad placements, e.g. [{"placement": "chat", "placement_id": "main"}]
productionbool | NoneNoneOverride the constructor-level production flag for this call
relevancyfloat | NoneNoneOverride 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 devicedevice.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.

Return types

@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
    # Lead-form envelope (present only for lead_form ad units)
    lead_form: LeadFormConfig | 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.
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.

Lead forms

When a campaign is configured as a lead_form ad unit, the AdResponse includes a lead_form field:
@dataclass
class LeadFormConfig:
    fields: list[LeadFormField]
    submit_url: str
    headline: str | None
    description: str | None
    cta_text: str | None
    success_message: str | None
    privacy_text: str | None
    consent_text: str | None
    success_redirect_url: str | None
    display: str              # "inline" | "modal-center" | "modal-bottom-right"
    prefill: LeadFormPrefill | None

@dataclass
class LeadFormField:
    name: str
    label: str
    type: str                 # "text", "email", "tel", etc.
    required: bool
    placeholder: str | None
    options: list[str] | None

@dataclass
class LeadFormPrefill:
    email: bool
    phone: bool
When present, render the form and POST the submitted values to lead_form.submit_url.

Experiments

When an experiment is active, every AdResponse carries the assigned variant, experiment_id, and composition_id. Impression and click URLs already encode the assignment — no extra tracking work.
ad = result.ads[0]
if ad.experiment_id:
    # Forward to whatever renderer you use. For React, pass ad.variant to
    # the <GravityAd /> component; for custom renderers, read ad.renderer_key
    # and ad.composition.
    ...