The pattern
Fire the ad request in parallel with your LLM call. Two reasons:- Latency budget. The user’s perceived latency is your LLM stream — the ad request should never block it.
- Fire and forget. Ad fetch can fail silently without affecting the user’s chat.
What’s in the request
Device signals
Thedevice 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.
Extra fields (
timezone, locale, browser, device_model, …) are accepted and stored.
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’sgravityContext() 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:- email:
sha256(email.strip().lower()) - phone:
sha256(re.sub(r"\D+", "", phone))(digits only)
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 HTTP200 with a JSON array of ad objects — one per requested placement:
{ 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)
FastAPI
Multiple placements
Request multiple placements in one call; they share the same auction: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:
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.
Next
Show ads
Render them in your UI.
API Reference
Full HTTP surface with all parameters.

