> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trygravity.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Understand what your users earn you

> Send user tags and model usage on your ad requests to see earnings and model cost per user group.

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.

> [!NOTE]
>
> Per-user analytics only count requests that carry a user ID. See [Send a user ID on every request](#send-a-user-id-on-every-request).

## Group users with tags

`user.tags` is a flat object of up to 10 keys:

```json theme={null}
"user": {
  "id": "user_789",
  "tags": { "plan": "free", "utm_source": "tiktok" }
}
```

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.

| Rule | Behavior |
| - | - |
| Keys | Lowercased. Must be lowercase letters, digits and underscores, 1–32 characters. Other keys are dropped. |
| Values | Strings, numbers or booleans. Stored as strings and trimmed to 64 characters. Empty values, nested objects and lists are dropped. |
| More than 10 valid keys | The first 10 in sorted order are kept. |
| Updates | Resend the complete tag map on every request. The latest pending map per UTC day wins; omitted keys from earlier requests that day aren't preserved. Persisted maps merge across completed days, with newer values winning per key. |

## 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:

```json theme={null}
"usage": { "model": "gpt-4.1-mini", "inputTokens": 1830, "cachedInputTokens": 1024, "outputTokens": 412 }
```

| Field | Type | Description |
| - | - | - |
| `model` | `string` | The model ID as the provider names it. |
| `inputTokens` | `integer` | Tokens read by this one model call, including its full prompt/context. Non-negative; not a cumulative total. |
| `cachedInputTokens` | `integer` | Optional cache-read tokens, a subset of `inputTokens`, not extra input. `cached_input_tokens` is also accepted. |
| `outputTokens` | `integer` | Tokens generated by this one model call. Non-negative; not a cumulative total. |
| `cost` | `number` | Optional. The USD you paid for this model call. See [Make the cost figure exact](#make-the-cost-figure-exact). |

* **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:

```json theme={null}
"usage": { "model": "openai/gpt-4.1-mini", "inputTokens": 1830, "outputTokens": 412, "cost": 0.00112 }
```

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

```bash API (direct HTTP) theme={null}
curl -X POST https://server.trygravity.com/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",
      "tags": {"plan": "free", "utm_source": "tiktok"}
    },
    "usage": {
      "model": "gpt-4.1-mini",
      "input_tokens": 1830,
      "input_tokens_details": {"cached_tokens": 1024},
      "output_tokens": 412
    },
    "device": {
      "ua": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ...",
      "ip": "203.0.113.42"
    }
  }'
```

```ts @gravity-ai/api (SDK) theme={null}
import { gravityContext, Gravity } from '@gravity-ai/api';

// Client: add tags next to the user ID.
const gravity_context = gravityContext({
  sessionId: chatSession.id,
  user: { userId: currentUser.id, tags: { plan: currentUser.plan } },
});

// Server: pass the provider's usage object with the model name.
const gravity = new Gravity({ production: true });

const { ads } = await gravity.getAds(
  req,
  messages,
  [{ placement: 'below_response', placement_id: 'main' }],
  { usage: { model: completion.model, ...completion.usage } },
);
```

```python gravity_sdk (SDK) theme={null}
from gravity_sdk import Gravity

gravity = Gravity(production=True)

# Tags travel in gravity_context.user, posted by your client:
# { "gravity_context": { "user": { "userId": "u123", "tags": { "plan": "free" } } } }

result = await gravity.get_ads(
    request,
    messages,
    [{"placement": "below_response", "placement_id": "main"}],
    usage={"model": completion.model, **completion.usage.model_dump()},
)
```

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](https://docs.trygravity.com/ai-platforms/request-ads.md)**
>
> Every field on the ad request.

> **[Payouts & analytics](https://docs.trygravity.com/ai-platforms/payouts.md)**
>
> How revenue and the dashboard work.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.