Player tags and traits#
Events tell us what happened. Tags and traits tell us who it happened to — the state you attach to a player rather than to a moment.
That is what makes comparison possible. Tag half your players team_a and half team_b, and
every event chart on your dashboard can be split between them. Record class: mage or
bought_season_pass: true as a trait, and the same is true of those.
Tag or trait?#
| Use a tag | Use a trait | |
|---|---|---|
| Shape | a label you either have or don't | a key with a value |
| Examples | team_a, beta_tester, whale |
class: mage, chapter: 7, spend_tier: high |
| Best for | splitting a population in two to compare | describing a player, and breaking down by value |
| Limits | 50 per player, 500 per project | 50 per player, 200 keys per project |
Both are always available as filters. The difference is cost: tags get a materialised cohort in our rollups automatically, so charts split by tag are fast without you doing anything. Traits get the same treatment once they prove to be low-cardinality, or when the studio enables it for a key explicitly in the dashboard.
POST /v1/players/update#
Up to 100 players per call. Requires events:write.
{
"players": [
{
"partner_user_id": "A1B2C3",
"tags_add": ["team_a", "beta_tester"],
"traits": { "class": "mage", "chapter": 7 },
"traits_once": { "first_build": "1.4.2" },
"traits_increment": { "bosses_killed": 1 }
}
]
}
Identify the player with partner_user_id, or with device_id when they are not signed
in — the same rule as events, and the device's tags fold into their real profile once they link.
| Field | Meaning |
|---|---|
traits |
Merged into what is there. Only the keys you send change. |
traits_once |
Set only if the key is not already present. Good for "first seen" values. |
traits_increment |
Numeric add, applied on our side. Use this for counters — a read-modify-write from two devices loses updates. |
traits_unset |
Remove these keys. |
tags_add / tags_remove |
Add or remove specific tags. Prefer these. |
tags |
Replaces the entire tag list. Only use it when you genuinely mean "these are now all of their tags". |
Response
{
"success": true,
"updated": [
{ "identity_key": "u:8a8c…", "partner_user_id": "A1B2C3",
"device_id": null, "gamearly_user_id": "8a8c…", "linked": true,
"tags": ["team_a", "beta_tester"] }
],
"rejected": [],
"server_time": "2026-08-20T10:00:03Z"
}
This writes immediately
Unlike /events/track, there is no queue and no lag. A tag set at login exists before the
next event is filtered by it. That is why the batch is smaller: this is one row per
player, sent when something changes, not one per moment.
Normalisation#
Tags are lowercased and spaces become underscores, so "Team A", "team a" and "team_a" are
all the same tag. That is deliberate — three spellings of one cohort would each look a third the
size. Trait keys keep their case; trait values are kept exactly as sent.
Anything that cannot be normalised — a tag with punctuation we don't allow, a key starting with
_ — is dropped from that entry rather than failing the call. Keys beginning with _ are
reserved for our own annotations.
POST /v1/players/get#
{ "partner_user_id": "A1B2C3" }
{
"found": true,
"profile": {
"identity_key": "u:8a8c…",
"partner_user_id": "A1B2C3", "device_id": null,
"gamearly_user_id": "8a8c…", "linked": true,
"tags": ["team_a", "beta_tester"],
"traits": { "class": "mage", "chapter": 7, "bosses_killed": 12 },
"first_seen_at": "…", "last_seen_at": "…"
}
}
found: false is not an error — a player you have never tagged simply has no profile yet.
POST /v1/players/search#
Find everyone in a cohort. Requires events:read.
{ "tags": ["team_a"], "traits": { "class": "mage" }, "page": 1 }
tags matches any of the listed tags by default; send "tags_match": "all" to require all
of them. Multiple traits are always ANDed.
Returns { "players": [ …profiles… ], "players_info": { "total", "pages", "page" } }.
POST /v1/players/tags#
The tags your project uses, with how many players carry each. Useful for building your own
picker, and for checking a rollout actually landed. Requires events:read.
{ "page": 1, "page_size": 50, "text": "team", "category": null }
{
"tags": [
{ "id": 7, "tag": "team_a", "label": "Team A", "category": "team",
"description": null, "is_hidden": false, "players_count": 4102,
"first_seen_at": "…", "last_seen_at": "…" }
],
"tags_info": { "total": 2, "pages": 1, "page": 1 },
"categories": ["team"]
}
page is required; page_size caps at 100.
Using cohorts in the event endpoints#
Every read endpoint that takes a date range also takes tags, tags_match and traits, with
exactly the shape above. So "how many bosses did team A kill this month" is /events/trends
with names: ["killed_boss"] and tags: ["team_a"].
Two things worth knowing#
Cohorts are evaluated as of now, not as of the event. A player who joined team_a yesterday
appears in team A's numbers for the whole period, including the days before they joined. That is
how product analytics tools treat user properties, and it is the useful answer for "how does team
A behave" — but if you need "who was in team A at the time", record it as an event property on
each event instead.
Tags are for cohorts, not for identifiers. A tag like team_a is right. A tag like
session_9f3a21 is not: the project cap of 500 exists to stop one such mistake turning the tag
registry into a second copy of your player table. Put per-player values in traits, and per-event
values in event properties.