Events reference
Every event the SDK accepts, the exact payload schema the server validates, and which dashboard surface each one feeds. Copy any snippet — these are the same shapes the ingest endpoint enforces via Pydantic.
backend/sdk/schema.py. The server rejects events that violate them; the dashboards rely on them. Extra fields you add are preserved on sdk_events.payload and queryable in the Event Explorer.Event envelope
Every event — regardless of type — is wrapped in this envelope. The payload object is what changes per event type and is documented below.
{
"event_id": "evt_3f9c1a2b...", // string, 8-40 chars, idempotency key
"ts": "2026-05-24T12:00:00Z", // ISO 8601 UTC; defaults to now if omitted
"platform": "roblox", // roblox | unity | steam | fortnite
"game_id": "12345678", // string, 1-64 chars, platform-native id
"player_id": "hashed_user_id", // string, ≤128 chars, NEVER raw PII
"session_id": "sess_xyz", // string, ≤64 chars, optional
"type": "session_start", // see "Standard events" below
"payload": { /* per-type schema */ }
}Ingest request & response
Batch up to 500 events per request. The api_key may be provided in the JSON body or as a Bearer token — pick one. Async-mode deployments return 202 with the X-RoLearn-Ingest-Mode: async header; on backpressure they transparently fall back to sync ingest and return 200. Either way the body shape is identical.
POST /api/sdk/events
Authorization: Bearer rk_live_XXXX
Content-Type: application/json
X-RoLearn-SDK-Version: 0.5.0
{
"api_key": "rk_live_XXXX",
"events": [ /* 1-500 Event objects */ ]
}HTTP/1.1 202 Accepted
X-RoLearn-Ingest-Mode: async
{
"accepted": 47,
"rejected": 0,
"deduped": 3,
"errors": []
}Validation rules
- Idempotency.
event_iddedups against a Redis set — re-POSTing the same id is safe and reported indeduped. - PII guard. Any payload containing one of
email,phone,phone_number,ssn,credit_card, orcc_number(case-insensitive) is rejected. Hashplayer_idclient-side. - Unknown event types are rejected. Stick to the taxonomy below or use
"type": "custom"with anamefield. - Rate limit. Per-key burst + sustained, enforced in Redis. Hitting it returns
429. - Quota. Monthly per-team event cap by plan (10K Free → 100M Enterprise). Over-quota returns
503+Retry-After: 15— never silent.
Standard events
Thirteen event types across five groups. Field constraints in the per-event sections below; this table is the at-a-glance map.
| Type | Group | Powers |
|---|---|---|
session_start | Lifecycle | DAU, MAU, new users, geo |
session_end | Lifecycle | Playtime (avg / p50 / p90), session length |
purchase | Monetization | Revenue, ARPU, ARPPU, paying users, per-country items |
ad_impression | Monetization | ARPDAU-ads, format / network split |
level | Gameplay | Funnel builder, retention drivers |
custom | Gameplay | Event Explorer, custom funnels, segment DSL |
performance | Quality | FPS histogram, p5 / p50 / p95 by device |
crash | Quality | Crashlytics-style signature groups, crash-free % |
error | Quality | Sentry-lite warn / error / fatal rollup |
brand_zone_enter | Brand activation | Brand-zone reach (distinct players × zone) |
brand_zone_exit | Brand activation | Brand-zone dwell (median seconds in zone) |
equip | Brand activation | Repeat-equip (players who equipped ≥ 2 days) |
share | Brand activation | Share funnel stage on the v2 dashboard |
brand_survey_shown | Brand Lift | Brand Lift survey impressions (exposed vs control) |
brand_survey_response | Brand Lift | Brand Lift — attitude lift (exposed vs control favorable %) |
Lifecycle
Auto-emitted on Roblox via PlayerAdded and PlayerRemoving. On Unity and Steamworks, call them from your app lifecycle hooks.
session_start — player joined
Powers: DAU, MAU, new users, geo
{
"device": "PC", // optional, free-form
"os": "Windows 11", // optional
"country": "US", // optional, ISO 3166-1 alpha-2
"membership": "premium" // optional, e.g. Roblox Premium tier
}Fields:
device(string, optional) — free-form device fingerprint (e.g. "PC", "Phone")os(string, optional) — operating system labelcountry(string, optional) — ISO 3166-1 alpha-2; cached so later events can omit itmembership(string, optional) — subscription tier label (e.g. Roblox "premium")
session_end — player left
Powers: Playtime aggregates (avg / p50 / p90), session length distribution
{
"duration_s": 312.4, // optional, seconds since session_start
"reason": "player_left", // optional, free-form
"country": "US" // optional, propagated from session_start
}Fields:
duration_s(number, optional) — seconds elapsed since the matching session_startreason(string, optional) — why the session ended (free-form)country(string, optional, ≤8) — propagated from session_start cache by the client
Monetization
purchase — player paid for something
Powers: Revenue, ARPU, ARPPU, paying users, per-country × item rollup
{
"sku": "vip_pass", // required, your item id
"name": "VIP Pass", // optional, display name
"currency": "USD", // default "USD"; use "ROBUX" for Robux
"price": 4.99, // required, ≥ 0
"quantity": 1, // default 1, ≥ 1
"source": "gamepass" // iap | gamepass | devproduct | subscription
// "country" optional, propagated from session_start
}Fields:
sku(string, required) — your stable item id (used for SKU breakdown chart)name(string, optional) — human-readable display namecurrency(string, default "USD") — currency code; use "ROBUX" for Robuxprice(number, required, ≥ 0) — price in the listed currency (sub-cent OK)quantity(integer, default 1, ≥ 1) — units purchasedsource(enum, default "iap") — "iap" | "gamepass" | "devproduct" | "subscription"country(string, optional, ≤8) — ISO country; propagated from session cache
ad_impression — ad shown to player (ILRD)
Powers: ARPDAU-ads, ad-revenue by format / network / country
{
"format": "rewarded", // banner | interstitial | rewarded | native
"network": "applovin_max", // required, 1-64 chars (mediation network name)
"placement": "level_complete",// optional, ≤64 chars (your surface name)
"revenue_usd": 0.0123, // required, ≥ 0 (publisher revenue for THIS impression)
"ecpm": 12.30, // optional, ≥ 0
"country": "US" // optional
}Fields:
format(enum, required) — "banner" | "interstitial" | "rewarded" | "native"network(string, required, 1-64) — mediation network name (e.g. "applovin_max")placement(string, optional, ≤64) — your surface name (e.g. "level_complete")revenue_usd(number, required, ≥ 0) — publisher revenue in USD for THIS impressionecpm(number, optional, ≥ 0) — reported eCPM if the network surfaces itcountry(string, optional, ≤8) — ISO country
Gameplay
level — level / round outcome
Powers: Funnel builder, retention drivers, segment DSL conditions
{
"level": 3, // required, integer ≥ 0
"score": 1200, // optional
"result": "complete" // win | loss | quit | complete (default "complete")
}Fields:
level(integer, required, ≥ 0) — level numberscore(integer, optional) — final score for this attemptresult(enum, default "complete") — "win" | "loss" | "quit" | "complete"
custom — anything else
Powers: Event Explorer, ad-hoc funnels, segment DSL (event_count + payload-attr filters)
{
"name": "tutorial_skipped", // required — your event name
// any additional fields you want, e.g.:
"step": 4,
"elapsed_s": 12.7
}Fields:
name(string, required) — your event name — this is what you query on…(any) — add any other fields; they’re preserved on the event row
Quality
Auto-captured on Roblox via RunService.Heartbeat + LogService.MessageOut when you enable capture_performance / capture_crashes in SDK.init. On Unity these are emitted by the SDK's built-in profiler at 1 Hz.
performance — FPS / frame-time / memory sample
Powers: FPS histogram, p5 / p50 / p95 series, per-device buckets
{
"fps_avg": 58.4, // required, 0-1000
"fps_p5": 42.0, // optional, 0-1000
"frame_time_ms_p95": 22.3, // optional, 0-10000
"memory_mb": 612.4, // optional, ≥ 0
"gpu": "NVIDIA RTX 4060", // optional, ≤128 chars
"device": "PC" // optional, ≤64 chars (buckets the histogram)
}Fields:
fps_avg(number, required, 0-1000) — average FPS over the sample windowfps_p5(number, optional, 0-1000) — 5th-percentile FPS (the bad-frame floor)frame_time_ms_p95(number, optional, 0-10000) — p95 frame time in msmemory_mb(number, optional, ≥ 0) — resident memory in MBgpu(string, optional, ≤128) — GPU label, if availabledevice(string, optional, ≤64) — device class — buckets the histogram
crash — hard crash with stack
Powers: Crashlytics-style signature groups (top-5 frames hashed), crash-free %
{
"stack_trace": "ScriptError: attempt to index nil with 'Position'\n at Module.update (Server/Combat.lua:142)\n ...",
"signature": "Combat.lua:142:nil_index", // optional pre-grouping key
"build": "1.4.2", // optional
"os": "Windows 11" // optional
}Fields:
stack_trace(string, required, 1-16000) — raw multi-line stack; aggregator hashes the top-5 normalized framessignature(string, optional, ≤255) — client-side pre-grouping key (e.g. error code); skipped if absentbuild(string, optional, ≤64) — your build / version stringos(string, optional, ≤64) — OS label
error — non-fatal warn / error / fatal
Powers: Severity rollup (warn / error / fatal counts), top error sources
{
"severity": "error", // warn | error | fatal
"message": "Datastore SetAsync failed: 502", // required, 1-2000 chars
"source": "PlayerData.save" // optional, ≤128 chars
}Fields:
severity(enum, required) — "warn" | "error" | "fatal"message(string, required, 1-2000) — error message; aggregated by (severity, source, message-prefix)source(string, optional, ≤128) — subsystem / module label
Brand activation
Power the brand-zone reach, dwell, repeat-equip, and share-funnel tiles on the CMO-flavored /sdk/dashboard. Until your game starts emitting these, the dashboard falls back to proxies (treat every player as engaged for reach, average session length as dwell, paying / DAU ratio as repeat-equip).
brand_zone_enter — player entered a branded zone
Powers: Brand-zone reach (distinct players × zone × country)
{
"zone_id": "main_stage", // required, 1-64 chars (your zone id)
"country": "US" // optional
}Fields:
zone_id(string, required, 1-64) — your zone id (e.g. "lobby_hat_shop", "main_stage")country(string, optional, ≤8) — ISO country
brand_zone_exit — player left a branded zone
Powers: Brand-zone dwell (median seconds per zone)
{
"zone_id": "main_stage", // required, must match the enter event
"dwell_seconds": 47.2, // required, 0-86400 (time in zone)
"country": "US" // optional
}Fields:
zone_id(string, required, 1-64) — must match the matching brand_zone_enterdwell_seconds(number, required, 0-86400) — time spent in the zone (client tracks this)country(string, optional, ≤8) — ISO country
equip — player equipped a branded item
Powers: Repeat-equip — players who equipped the same item on ≥ 2 distinct UTC days
{
"item_id": "branded_hat_07", // required, 1-64 chars
"item_name": "Sponsor Cap — Red", // optional, ≤128 chars
"country": "US" // optional
}Fields:
item_id(string, required, 1-64) — stable item iditem_name(string, optional, ≤128) — human-readable namecountry(string, optional, ≤8) — ISO country
share — player shared content externally
Powers: Share stage on the v2 dashboard funnel
{
"share_platform": "twitter", // required, 1-32 chars (destination)
"item_id": "branded_hat_07",// optional — tie to a branded asset
"country": "US" // optional
}Fields:
share_platform(string, required, 1-32) — destination — "twitter", "discord", etc.item_id(string, optional, ≤64) — tie the share to a specific branded assetcountry(string, optional, ≤8) — ISO country
brand_survey_shown — a Brand Lift survey card was shown to a player
Powers: Brand Lift survey impressions, split by exposed / control cohort
{
"survey_id": "42", // required — the Brand Lift study id
"cohort": "exposed", // "exposed" (entered the zone) | "control"
"country": "US" // optional
}Fields:
survey_id(string, required, 1-64) — the Brand Lift study idcohort(string, optional, ≤16) — "exposed" (entered the brand zone) or "control"country(string, optional, ≤8) — ISO country
brand_survey_response — a player answered a Brand Lift survey
Powers: Brand Lift — exposed-vs-control favorable-rate lift, significance-tested, fed into OMNI-EMV DBI
{
"survey_id": "42", // required — the study id
"answer": "likely", // required — the chosen answer key (fixed-choice)
"favorable": true, // whether that answer is a positive brand outcome
"cohort": "exposed", // "exposed" | "control"
"country": "US" // optional
}Fields:
survey_id(string, required, 1-64) — the study idanswer(string, required, 1-64) — the chosen answer key (fixed-choice — never free text)favorable(bool, optional) — whether that answer counts as a positive brand outcomecohort(string, optional, ≤16) — "exposed" | "control"country(string, optional, ≤8) — ISO country
These two are emitted automatically by the runtime's Brand Lift survey flow — you don't call them yourself. You wire the exposed side with RoLearn.trackBrandZoneEnter(player, { zone_id = "…" }); the control arm and the response capture happen on their own.
Custom events — when to use them
Use "type": "custom" for anything outside the taxonomy above. The name field is what you'll filter on in the Event Explorer and reference in the segment DSL. A few rules of thumb:
- Don't reinvent standard events. A custom event called
"bought_item"won't show up in revenue charts —purchasewill. - Keep
namestable. It's the join key for every segment, funnel, and dashboard tile that references it. Rename it and historical analytics break. - Keep cardinality bounded. A
namethat embedsplayer_idor a timestamp will balloon the index. Put high-cardinality values in payload fields, not in the name. - Add fields freely. Anything in the payload is preserved and queryable. The PII guard is the only restriction.
Roblox helper recipes
The Roblox SDK exposes SDK.purchase() and a generic SDK.track(type, payload, player?). Lifecycle and quality events fire automatically when enabled. Equivalent Unity (RoLearnSDK.Track) and Steamworks (rolearn::SDK::instance().track) APIs follow the same shape — see Install for syntax.
local SDK = require(game.ServerScriptService.RoLearnSDK)
-- Lifecycle events fire automatically on PlayerAdded / PlayerRemoving.
-- You only need to call these helpers for the rest:
-- Purchase (recommended: also call from MarketplaceService.ProcessReceipt)
SDK.purchase(player, {
sku = "vip_pass", price = 99, currency = "ROBUX", source = "gamepass",
})
-- Custom gameplay event
SDK.track("level", { level = 3, score = 1200, result = "win" }, player)
-- Ad impression (call inside your reward callback)
SDK.track("ad_impression", {
format = "rewarded", network = "applovin_max",
placement = "level_complete", revenue_usd = 0.0123,
}, player)
-- Brand-zone pairing (enter on touch, exit on leave with dwell timing)
SDK.track("brand_zone_enter", { zone_id = "main_stage" }, player)
-- ... when player leaves the zone:
SDK.track("brand_zone_exit", { zone_id = "main_stage", dwell_seconds = 47.2 }, player)
-- Equip / share (fire from your existing equipment + share UI)
SDK.track("equip", { item_id = "branded_hat_07", item_name = "Sponsor Cap" }, player)
SDK.track("share", { share_platform = "twitter", item_id = "branded_hat_07" }, player)
-- Performance + crash are auto-captured via Heartbeat + LogService when
-- you enable them in SDK.init({ capture_performance = true, capture_crashes = true }).Next steps
- Integration reference — auth scoping, quotas, GDPR delete, key rotation.
- Event Explorer — search every event you've sent, filter by type / player / game.
- Segments — build player segments from event_count and payload-attribute conditions.
- Dead-letter queue — inspect (and replay) anything the ingest endpoint rejected.

