Integration reference
Everything you need to wire your game's events to ROLearn. This page is platform-agnostic — it documents the wire format that every embed client speaks. If you're starting with copy-paste snippets, see Install first.
Canonical event
Every event the embed clients emit conforms to this shape. The server validates it via backend/sdk/schema.py and rejects anything malformed (rejected events are visible to you in /sdk/dlq).
{
"event_id": "evt_3f9c1a2b...", // 8-40 chars, idempotency key
"ts": "2026-05-08T12:00:00Z",
"platform": "roblox" | "unity" | "steam" | "fortnite",
"game_id": "12345678", // platform-native id
"player_id": "hashed_user_id", // 128 chars, NEVER raw PII
"session_id": "sess_xyz", // optional
"type": "session_start" | "session_end" |
"purchase" | "level" | "custom" |
"ad_impression" | "performance" |
"crash" | "error" |
"brand_zone_enter" | "brand_zone_exit" |
"equip" | "share",
"payload": { ...per-type schema — see /sdk/learn/events... }
}Purchase payload
The purchase type has a strict schema — these fields drive the revenue dashboard. Other types (level,custom, session_*) accept open dicts.
{
"sku": "vip_pass",
"name": "VIP Pass",
"currency": "ROBUX" | "USD" | "EUR" | "...", // Roblox defaults to ROBUX (v0.4.0+)
"price": 99, // Robux units OR fiat (matches currency)
"quantity": 1,
"source": "iap" | "gamepass" | "devproduct" | "subscription" | "in_game"
}Platform-specific integration you must wire yourself
The SDK auto-tracks everything each platform safely lets it — sessions, country, crashes, FPS, and (on Roblox) gamepass purchases. But every platform has surfaces that cannot be auto-hooked without breaking your game (e.g. Roblox's ProcessReceipt can only have one handler — yours). Those require a one-line call from your code. Below is the complete list, per engine.
Roblox (Lua)
ProcessReceipt) purchase tracking for you — so the "manual wiring" subsections below apply only to a hand-rolled install. You still wire in-game / soft-currency spend yourself either way.Roblox has three monetization surfaces — gamepasses, developer products, and in-game Robux/soft-currency spending. With a manual install only gamepasses auto-track; the plugin's init script also covers developer products.
What's auto-tracked (zero code)
- Gamepasses via
MarketplaceService.PromptGamePassPurchaseFinished. OnceSDK.init()runs, every gamepass purchase emits apurchaseevent withsource="gamepass"andcurrency="ROBUX"automatically. - Sessions via
Players.PlayerAdded/PlayerRemoving—session_start/session_endare auto-emitted. - Country via
LocalizationService:GetCountryRegionForPlayerAsync(since v0.4.0) — cached once on player join, attached to every subsequent event for that player. - Crashes & errors via
LogService.MessageOut— multi-line errors grouped ascrashevents, single-line aserrorevents. - FPS performance sampled via
RunService.Heartbeat— one snapshot emitted persession_end.
Manual wiring required — Developer Products
Roblox lets exactly one function be assigned to MarketplaceService.ProcessReceipt, and that function is responsible for granting the item AND returning PurchaseGranted. If the SDK overwrote it, your item-delivery code would never run. So you must add the RoLearnSDK.purchase() call inside your own ProcessReceipt:
local MarketplaceService = game:GetService("MarketplaceService")
local RoLearnSDK = require(game.ServerScriptService.RoLearnSDK)
local function processReceipt(receipt)
local player = game.Players:GetPlayerByUserId(receipt.PlayerId)
if not player then return Enum.ProductPurchaseDecision.NotProcessedYet end
-- 1. Grant the product to the player.
-- ...your existing logic to give them coins / gems / items...
-- 2. Tell ROLearn about it.
RoLearnSDK.purchase(player, {
sku = tostring(receipt.ProductId),
price = receipt.CurrencySpent,
currency = "ROBUX",
source = "devproduct",
})
return Enum.ProductPurchaseDecision.PurchaseGranted
end
MarketplaceService.ProcessReceipt = processReceiptManual wiring required — in-game spending
Custom shops, premium payouts, hard-currency gates — any place the player spends a value inside your game (vs. through Roblox's purchase prompt) also needs a manual call. Pass your soft-currency code if it's not Robux.
-- Player spending hard or soft currency in your custom shop
RoLearnSDK.purchase(player, {
sku = "skin_purple_dragon",
name = "Purple Dragon Skin",
price = 75, -- Robux (or in-game currency unit)
currency = "ROBUX", -- or your soft-currency code, e.g. "COINS"
source = "in_game",
})Sanity test before adding real wiring
Fastest way to prove the full pipeline (SDK → ingest → DLQ → dashboard) works before you touch any game code. Paste into Studio's Command Bar:
-- Paste into Studio's Command Bar with at least one test player connected.
local RoLearnSDK = require(game.ServerScriptService.RoLearnSDK)
local player = game.Players:GetPlayers()[1]
RoLearnSDK.purchase(player, {
sku = "test_sanity_99",
name = "Sanity check",
price = 99,
currency = "ROBUX",
source = "devproduct",
})
-- → event lands in /sdk/events within ~10s. If not, check /sdk/dlq.- Currency default — since v0.4.0, omitting
currencydefaults to"ROBUX". In v0.3.x it incorrectly defaulted to"USD", which silently excluded every auto-tracked gamepass purchase from the revenue dashboard. Upgrade to v0.4.0+ if you're on the older version. - Server-side only — never
require()RoLearnSDK from a LocalScript. Theapi_keyfield would be exposed to clients. - Studio test mode — country resolves to
"XX"when running in Studio. Real ISO country codes appear only after the game is published and played by real users. - HTTP Requests — must be enabled in
Game Settings → Security → "Allow HTTP requests". Without this, every event silently fails. - Place_id required — the game must be published.
game.PlaceIdis0in unpublished places and the ingest endpoint will 403 the events.
Unity (C#)
What's auto-tracked (zero code)
- Sessions via Unity's lifecycle callbacks —
session_startonInit(),session_endonOnApplicationQuit. - Unity IAP purchases via the shipped IAP listener — auto-tracks when your project has
UNITY_PURCHASINGenabled. Source is set to"iap", currency to the localized currency code fromProduct.metadata. - Crashes via
Application.logMessageReceivedThreaded— Exception-level logs ship ascrashevents with the full stack.
Manual wiring required — non-Unity-IAP stores
If you ship to platforms outside Unity IAP's coverage (custom storefronts, Epic Online Services, direct Stripe, Steam via Steamworks.NET, etc.), the IAP listener has nothing to hook. Call RoLearnSDK.Purchase()at the point your code grants the entitlement:
// Custom store / Epic / Steam / non-Unity-IAP storefronts:
// call SDK.Purchase yourself at the point you grant the entitlement.
RoLearnSDK.Purchase(new PurchaseInfo {
Sku = "battle_pass_season_4",
Name = "Season 4 Battle Pass",
Price = 9.99,
Currency = "USD",
Source = "iap",
});Manual wiring required — mobile lifecycle
On Android & iOS, players who background the app don't fire OnApplicationQuit. Without the hook below, those sessions stay "open" until the autosave timeout (~5 min), inflating average session length. Wire OnApplicationPause in your bootstrap MonoBehaviour:
// Mobile lifecycle — without this, backgrounded players show as
// "still in session" for the autosave timeout window (~5 min).
void OnApplicationPause(bool paused) {
if (paused) RoLearnSDK.FlushAndPauseSession();
else RoLearnSDK.ResumeSession();
}
void OnApplicationQuit() {
RoLearnSDK.FlushSync(); // blocks <= 2s while in-flight events drain
}- IL2CPP stripping — Unity's IL2CPP build pipeline can strip SDK reflection paths. The shipped
link.xmlprotects the public surface; do NOT add the SDK assembly to any custom strip exclusion list (it'll bloat your binary by ~400KB without benefit). - iOS privacy manifest — Apple now requires a
PrivacyInfo.xcprivacydeclaration for SDKs that read device identifiers. ROLearn ships one in the package — don't override it. - Editor vs. build —
Application.identifieris the package name in builds but defaults tocom.DefaultCompany.<ProjectName>in the Editor. Use a stableGameIdstring in your config to avoid Editor sessions polluting your prod game's data.
Steamworks (C++)
What's auto-tracked (zero code)
- Sessions via the SDK's
SteamAPI_RunCallbacksheartbeat —session_startoninit(),session_endonshutdown(). - SteamID attachment — when the Steamworks SDK is detected,
ISteamUser::GetSteamID()is attached to every event asplayer_id(hashed). No call needed beyond initializing Steam before ROLearn. - Live concurrent users via the
ISteamUserStatshook (since v0.2.0) — periodic capture, ships assession_startbeacons.
Manual wiring required — microtransactions
Steam's ISteamMicroTxn doesn't expose a "purchase finished" callback the SDK can listen to (unlike Roblox MarketplaceService). You poll order status server-side and emit the event on transition to Authorized or Approved:
// ISteamMicroTxn doesn't give a "purchase finished" callback you can hook
// like Roblox MarketplaceService does. You poll OrderStatus and emit on
// transition to "Authorized" or "Approved". Pattern:
void OnOrderAuthorized(uint64_t order_id, const std::string& sku, double usd) {
rolearn::SDK::instance().purchase({
.sku = sku,
.price = usd,
.currency = "USD",
.source = "iap",
});
}- Steam-init order — call
SteamAPI_Init()BEFORErolearn::SDK::instance().init(). If ROLearn initializes first, SteamID attachment falls back to a generated UUID (still works, but cross-game identity won't link). - Thread safety — the SDK singleton is thread-safe (since v0.2.0), but the payload
std::mapyou pass totrack()is moved, not copied. Don't mutate it on another thread after the call. - Linker order — link order must be: your game →
rolearn_sdk→libcurl→nlohmann/json. Reversing curl and SDK produces "undefined symbol: curl_easy_init" on Linux builds with-Wl,--as-needed.
Fortnite / UEFN (Verse)
What's auto-tracked (zero code)
- Player joined / left via the standard
creative_device.OnBeginpattern — emitssession_start/session_end. - Island lifecycle — round-start and round-end events when your island uses the standard
fort_playspaceprimitives.
Manual wiring — custom events
UEFN's Verse sandbox restricts what an SDK can observe. Custom events and player progress work; in-game V-Bucks tracking is NOT exposed by Epic's Verse API and cannot be auto-instrumented (Epic owns that data surface).
# UEFN sandbox: the SDK exposes only what Verse permits.
# Custom events + session lifecycle work; in-game V-Bucks tracking
# is NOT exposed by Epic's Verse API and cannot be auto-instrumented.
RoLearn.Track("level_complete", props := {
level := 3,
score := 1200,
})- UEFN version pinning — Verse modules pin to a UEFN engine version. When you upgrade UEFN, re-import the latest ROLearn Verse module from /sdk/learn/downloads.
- No client-side events — Verse runs on the server-equivalent of UEFN; there's no client-script equivalent. All instrumentation is from the game-logic surface.
- Limited monetization surface — by Epic's design, ROLearn cannot see V-Bucks spending. Use Epic's first-party analytics for monetization KPIs; use ROLearn for player behavior + retention.
Authentication
Each request carries an api_key field. Keys are team-scoped and prefixed rk_live_. Create + rotate them in /sdk/games (the SDK setup hub); rotation is zero-downtime with a 7-day grace window on the old key.
Keys are also game-scoped — every event in a batch must reference a game your team has linked. Events with unlinked game_id values are 403'd and copied to the DLQ for inspection.
Ingest endpoint
POST /api/sdk/events
Content-Type: application/json
X-RoLearn-SDK-Version: 0.5.0
{
"api_key": "rk_live_XXXX",
"events": [ /* up to 500 Event objects */ ]
}Returns { accepted, deduped, rejected }. The X-RoLearn-SDK-Version header is optional but recommended — when present, the server enforces a minimum supported version and returns 426 Upgrade Requiredif your client is too old.
Idempotency
The server dedups on event_id with a rolling 7-day window. Retries with the same id are silently dropped — safe to retry on flaky networks. New ids past 7 days WILL count again, so use stable per-event UUIDs, not "retry counter" values.
Rate limits & quotas
Two limits stack:
- Per-key rate limit — sliding minute window, default 6,000 events/min. Configurable per key.
- Per-team monthly quota — driven by your SDK plan. See the table below.
| SDK plan | Events / month | Linked games | Advanced features |
|---|---|---|---|
| Free | 10,000 | 1 | Aggregator + dashboard |
| Starter — $29 / mo | 100,000 | 3 | + Live event log |
| Growth — $199 / mo | 5,000,000 | 10 | + Funnel builder, retention |
| Scale — $999 / mo | 50,000,000 | 100 | + Live stream, data export, A/B testing, 99.9% SLA |
| Enterprise | 100M+ | unlimited | + A/B testing, 99.99% SLA, signed DPA, success manager |
A/B testing · Scale + Enterprise plans
Phase 8.1 (shipped 2026-05-26) wires A/B testing end-to-end. You define experiments in /sdk/experiments — name, variants (with traffic weights), traffic %, status — and the embed clients call getVariant / GetVariantAsync / get_variant to retrieve a player's sticky deterministic assignment. The server uses SHA1 over (experiment_seed, player_id) so the same player always gets the same variant across servers and sessions until the experiment closes.
Results (per-variant DAU + ARPDAU + 2-proportion z-test + Welch t-test) are computed daily by the SDK aggregation pipeline and rendered in the same console.
Roblox
-- Phase 8.1: sticky deterministic variant assignment.
-- Per-server 1h cache keyed by (experimentId, playerId), so calling
-- getVariant per-event is safe — the assign endpoint is hit at most
-- once per player per hour.
local RoLearnSDK = require(game.ServerScriptService.RoLearnSDK)
game.Players.PlayerAdded:Connect(function(player)
local variant = RoLearnSDK.getVariant(player, 42) -- 42 = experiment_id
if variant == "treatment" then
-- ship the new shop layout
elseif variant == "control" then
-- ship the old shop layout
else
-- variant == nil → not in experiment (fall back to default)
end
end)Unity
// Phase 8.1: GetVariantAsync returns Task<string>.
// 1h in-memory cache keyed by ($"{experimentId}:{playerId}").
var variant = await RoLearnSDK.GetVariantAsync(42, playerId);
if (variant == "treatment") {
// ship the new tutorial
} else if (variant == "control") {
// ship the old tutorial
} else {
// variant == null → not in experiment / fetch failed
}Steamworks
// Phase 8.1: get_variant blocks on libcurl, mutex-guarded 1h cache.
// Returns empty string if the player is not in the experiment OR if
// the assign call fails — fall back to your default variant.
auto variant = rolearn::SDK::instance().get_variant(42, player_id);
if (variant == "treatment") {
// ship the new pricing
} else if (variant == "control") {
// ship the old pricing
} else {
// not in experiment / fetch failed
}- Sticky — assignments are stored in
sdk_experiment_assignmentwith a UNIQUE constraint on(experiment_id, player_id). The first call wins; every subsequent call for that player returns the same variant. - Deterministic — variant choice is
sha1(experiment.seed + ":" + player_id)bucketed against cumulative traffic weights, so even a fresh server with an empty cache returns the same answer. - Cached client-side — every SDK caches the assignment for 1 hour in process memory. Per-event calls are safe; the network hit happens at most once per player per hour.
- Fail-safe — network errors, an invalid api_key, or a non-running experiment all return
nil/null/"". Always have a default branch.
Privacy & GDPR
The server rejects any payload containing fields namedemail, phone, ssn,credit_card, or other obvious PII shapes — defense in depth, since clients SHOULD never send those. Hash player ids before sending; the SDKs ship a before_send(event)hook for last-mile redaction.
A right-to-be-forgotten endpoint hard-deletes every event for a given player_id within your team's data:
DELETE /api/sdk/data/players/{player_id}Errors
| Status | Meaning | What to do |
|---|---|---|
400 | Malformed payload | Check the event schema; the response body lists which fields failed. |
401 | Invalid api_key | Verify the key is active in /sdk/games. |
403 | Game not linked / scope error | Link the game in /sdk/games. |
422 | PII detected in payload | Strip PII fields client-side before sending. |
426 | SDK client too old | Upgrade to the version listed in the response. |
429 | Rate limit or quota exceeded | Retry after the seconds in Retry-After. |
5xx | Server error | Retries are safe (idempotent on event_id). |
