Skip to main content
Two optional fields on the ad request let your dashboard answer a question that totals can’t: which users earn more than they cost? Send user.tags to group your users, and usage to report the model response behind each ad request. Both are optional, and neither changes which ads you get. In Audience → Users you’ll see, by country, tag and model:
  • earnings per 1,000 messages
  • model cost per 1,000 messages
  • Profitability: ad earnings ÷ model cost. 100% is break-even; above 100% is green and below is red. This isn’t a conventional profit margin.
In the Users tab, click + Filter, choose a dimension (country, model, messages per day or new vs returning), then select one or more values. For tags, choose Tags → key → values. Only active filters appear as pills. The breakdown starts collapsed, grouped by Country, then Model: expand a country to compare each model’s earnings, model cost and Profitability, and see which models you can afford to serve there. Earnings/user compares messages-per-day cohorts. Groups under 50 users aren’t shown.
Per-user analytics only count requests that carry a user ID. See Send a user ID on every request.

Group users with tags

user.tags is a flat object of up to 10 keys:
Tags are used only to group users in your own analytics. They’re never used for ad targeting and never shared with advertisers. Groups with fewer than 50 users aren’t shown. Start with plan. Send plan as free or paid first. It shows what your free users earn, which is the quickest way to see whether ads pay for their model cost.

Report model usage

usage sits at the top level of the ad request. When you request an ad on a new user message, send the previous completed assistant reply’s usage from that same conversation. The new model call hasn’t finished yet. Omit usage on the first turn; save each completed reply’s provider usage for the next turn’s ad request and report it only once. Do not send running totals for the conversation, session or user.
  • Input tokens: everything the model read for that one call, including the system prompt, conversation history and other context sent with it. This is not just the latest user message.
  • Output tokens: the tokens the model generated in that one reply.
Use the usage object returned by your model provider for that call. For example, if it reports 1,830 input tokens (including 1,024 cached tokens) and 412 output tokens, send those numbers unchanged:
  • It never fails a request. Malformed values are ignored.
  • Provider keys work as-is. input_tokens / output_tokens and prompt_tokens / completion_tokens are also accepted, so you can spread a provider’s usage object straight in.
  • Send model even without token counts. Earnings can still be broken down by model.
  • Don’t wait for the new model call. Request the ad in parallel, carrying the previous completed reply’s usage. If the conversation ends, that final reply’s usage won’t be reported through a later ad request.
  • If you instead request ads after the reply finishes, send that completed reply’s usage then, not again on the next turn.
  • One model response per ad request, not per placement. If you use a bid/render integration, send usage on the bid request, not again on render.

Make the cost figure exact

cost is optional and only makes the cost figure exact. It’s the USD you paid for the model call behind this reply, from 0 to 100:
  • When cost is present, the dashboard uses it instead of the list-price estimate.
  • When it’s absent, cost is estimated from the model maker’s public list price. Gravity doesn’t need your actual costs or discounts.
  • 0 means a free call, such as a self-hosted model.
  • Invalid values are ignored without failing the request. cost_usd is accepted as an alias.
OpenRouter already returns the cost as usage.cost, so usage: { model: res.model, ...res.usage } carries it through with no extra code.

Send both

In these examples, completion is the previous completed model response in this conversation, saved when that reply finished. On the first turn, leave out usage rather than sending zeros.
The spreads above preserve OpenAI-compatible cache-read details: Responses returns input_tokens_details.cached_tokens, and Chat Completions returns prompt_tokens_details.cached_tokens. Both are accepted directly, as in the HTTP example. OpenAI’s input/prompt totals already include cached tokens. Don’t add or subtract them yourself: the list-price estimate uses the published cache-read rate for the cached subset and the normal input rate for the rest. When no cache-read rate is known, cached tokens use the ordinary input rate. Malformed cache counts, including counts greater than total input, are ignored. A valid usage.cost, including 0, still overrides the estimate. This cache format follows OpenAI semantics, not full Anthropic cache accounting. Anthropic’s input_tokens excludes cache reads and writes, so spreading its raw usage doesn’t provide a full cache-aware estimate. Send the actual cost when you know it.

Send a user ID on every request

Per-user analytics, including everything on this page, only count requests that carry a user ID other than the literal "anonymous". Requests without one still earn, but they drop out of the Users views. If some of your users can use the app signed out, send a stable anonymous ID for them as user.id (user.userId in gravityContext()), such as an install ID or device ID that you generate once and keep. Don’t use one shared "anonymous" value. Email alone isn’t used as a user key, so a hashed email doesn’t replace user.id.

Next

Request ads

Every field on the ad request.

Payouts & analytics

How revenue and the dashboard work.