LiveOps SDK reference
Send events from your game and read remote config over HTTPS. Endpoints, keys, event fields, limits, errors and copy-paste code for every engine.
The SDK is a small piece of code in your game that does three things: batches events and sends them every 15 seconds or so, tracks sessions, and fetches remote config with the player's A/B test variants. There's nothing to install: the code for each engine is below and on your game's SDK setup tab, already filled in with your key. Anything that can make an HTTPS request can use it.
Your key
Every game has its own key, starting with dn_live_. Find it on the game's SDK setup tab, where you can show, copy or replace it.
- The key can only send events and read that game's config. It can't read your numbers or change anything, so it's fine inside a game build.
- On Roblox, keep it in server scripts anyway, away from the client.
- If it leaks and someone sends junk, select New key. The old key stops working right away, so update your game first.
- Archiving a game turns its key off.
Send the key in any of these ways. Use the first one your engine supports:
| Where | Example |
|---|---|
X-DevNet-Key header |
X-DevNet-Key: dn_live_… |
Authorization header |
Authorization: Bearer dn_live_… |
key field in the JSON body (events only) |
{"key": "dn_live_…", "events": […]} |
key query parameter |
…/api/liveops/v1/config?key=dn_live_… |
Send events
POST https://devnet.lol/api/liveops/v1/events
Content-Type: application/json
X-DevNet-Key: dn_live_your_key_here
{
"events": [
{ "name": "session_start", "player": "player-123" },
{ "name": "purchase", "player": "player-123", "value": 4.99, "props": { "item": "starter_pack", "currency": "usd" } }
]
}
Send up to 100 events per request in events, or a single event object on its own. The reply says how many were stored:
{ "ok": true, "accepted": 2, "rejected": 0 }
A bad event is skipped and counted in rejected; it never fails the rest of the batch.
Event fields
| Field | Required | What it is |
|---|---|---|
name |
Yes | The event name. Stored in lowercase. 1 to 64 characters: letters, numbers and _ . : $ -. |
player |
Yes | A stable ID for the player, as a string or number, up to 128 characters. playerId and userId also work. |
value |
No | A number: the session length in seconds, a price, a level. |
session |
No | A session ID, up to 128 characters, if you track sessions yourself. sessionId also works. |
props |
No | A JSON object of extra details, up to 2,048 bytes. Larger props are dropped, but the event is kept. |
time |
No | When it happened: Unix seconds, Unix milliseconds or an ISO 8601 date. timestamp also works. Times up to 7 days old are kept, so offline queues work. Times more than 5 minutes in the future, older than 7 days or unreadable are replaced with the time DevNet received the event. |
Use a player ID that doesn't change: the Roblox UserId, Minecraft UUID, SteamID64 or your own account ID. Retention, funnels and A/B tests all depend on it.
Events DevNet understands
These four power the dashboard's numbers. Anything else is a custom event (tutorial_done, boss_killed, shop_opened…) that you can use in funnels and as an A/B test goal.
| Event | When to send it |
|---|---|
session_start |
A player joined. Starts a session. |
session_end |
A player left. Send the session length in seconds as the value. |
purchase |
A player bought something. Send the price as the value and the currency in props. |
level_up |
Progress milestone. Send the new level as the value. |
From these, DevNet works out each day's players, new players, sessions, playtime, average session length, revenue, purchases, paying players, and D1 and D7 retention. Sessions are counted by session when you send one, or by session_start events when you don't. Revenue is the sum of purchase values: the dashboard shows it in Robux for Roblox games and in US dollars for everything else, so send prices in that currency.
Read remote config
GET https://devnet.lol/api/liveops/v1/config?player=player-123
X-DevNet-Key: dn_live_your_key_here
{
"ok": true,
"config": { "halloween_enabled": true, "starter_pack_price": 49 },
"experiments": { "starter_pack_price_test": "test" },
"fetchedAt": "2026-10-30T12:00:00.000Z"
}
configis every key from the game's remote config, with the player's A/B test overrides applied on top.experimentsmaps each running test's key to the variant this player is in.- Pass
player(up to 128 characters) to get that player's variants. Without it you get the plain config and no variants.
Config is cached for up to 30 seconds. If the call fails, keep using the last values you had and try again later. Always ship defaults in your code for when a key is missing.
Limits and errors
- Up to 100 events and 256,000 characters per request.
- About 120 requests a minute per game. Batch events and send every 10 to 30 seconds; the snippets below send every 15.
- Raw events are kept for 90 days. The daily numbers made from them are kept after that.
- New events show up in the dashboard's daily numbers within about 10 minutes.
- Both endpoints allow requests from any origin, so browser builds (Unity WebGL, Godot HTML5) work.
| Status | Meaning | What to do |
|---|---|---|
200 |
Stored (events) or here's the config. | Nothing. |
400 |
The body isn't JSON. | Send a JSON object. |
401 |
The key is missing, wrong, replaced, or its game is archived. | Check the key on the SDK setup tab. |
413 |
More than 100 events, or the body is too large. | Send smaller batches. |
429 |
Too many requests for this game. | Batch events and send less often. |
500 |
DevNet couldn't store or load it. | Retry shortly. For config, keep your last values. |
Code for your engine
Each snippet sends session_start and session_end for you and gives you a track call for everything else. Swap in your own key, or copy the filled-in version from your game's SDK setup tab.
ServerScriptService › DevNet (ModuleScript)
-- DevNet LiveOps for Roblox. Put this ModuleScript in ServerScriptService.
local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")
local KEY = "dn_live_your_key_here"
local EVENTS_URL = "https://devnet.lol/api/liveops/v1/events"
local CONFIG_URL = "https://devnet.lol/api/liveops/v1/config"
local DevNet = { config = {}, experiments = {} }
local queue, joinedAt = {}, {}
function DevNet.track(player, name, value, props)
table.insert(queue, {
name = name,
player = tostring(typeof(player) == "Instance" and player.UserId or player),
value = value,
props = props,
time = os.time(),
})
end
local function flush()
if #queue == 0 then return end
local batch = queue
queue = {}
local ok, err = pcall(function()
HttpService:RequestAsync({
Url = EVENTS_URL,
Method = "POST",
Headers = { ["Content-Type"] = "application/json", ["X-DevNet-Key"] = KEY },
Body = HttpService:JSONEncode({ events = batch }),
})
end)
if not ok then warn("[DevNet] send failed:", err) end
end
-- Remote config, with this player's A/B test variants applied
function DevNet.getConfig(player)
local url = CONFIG_URL .. "?player=" .. tostring(player and player.UserId or "")
local ok, res = pcall(function()
return HttpService:RequestAsync({ Url = url, Method = "GET", Headers = { ["X-DevNet-Key"] = KEY } })
end)
if ok and res.Success then
local data = HttpService:JSONDecode(res.Body)
return data.config, data.experiments
end
return DevNet.config, {}
end
Players.PlayerAdded:Connect(function(player)
joinedAt[player] = os.clock()
DevNet.track(player, "session_start")
end)
Players.PlayerRemoving:Connect(function(player)
DevNet.track(player, "session_end", math.floor(os.clock() - (joinedAt[player] or os.clock())))
joinedAt[player] = nil
end)
game:BindToClose(flush)
task.spawn(function()
DevNet.config = DevNet.getConfig(nil)
while true do
task.wait(15)
flush()
end
end)
return DevNet
-- Usage, from any server script:
-- local DevNet = require(game.ServerScriptService.DevNet)
-- DevNet.track(player, "purchase", 99, { item = "Speed Coil", currency = "robux" })Turn on Game Settings › Security › Allow HTTP Requests. Require it from a server Script; keep the key out of client code.
Assets/Scripts/DevNet.cs
// DevNet LiveOps for Unity. Add this component to a GameObject in your first scene.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
public class DevNet : MonoBehaviour
{
const string Key = "dn_live_your_key_here";
const string EventsUrl = "https://devnet.lol/api/liveops/v1/events";
const string ConfigUrl = "https://devnet.lol/api/liveops/v1/config";
public static DevNet Instance { get; private set; }
public string PlayerId { get; private set; }
public string ConfigJson { get; private set; } = "{}";
readonly List<string> queue = new List<string>();
float sessionStart;
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
PlayerId = PlayerPrefs.GetString("devnet_player", Guid.NewGuid().ToString("N"));
PlayerPrefs.SetString("devnet_player", PlayerId);
sessionStart = Time.realtimeSinceStartup;
Track("session_start");
StartCoroutine(FetchConfig());
InvokeRepeating(nameof(Flush), 15f, 15f);
}
public void Track(string name, double? value = null, string propsJson = null)
{
var e = new StringBuilder("{");
e.Append("\"name\":\"").Append(name).Append("\",\"player\":\"").Append(PlayerId).Append("\"");
if (value.HasValue) e.Append(",\"value\":").Append(value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
if (!string.IsNullOrEmpty(propsJson)) e.Append(",\"props\":").Append(propsJson);
e.Append(",\"time\":").Append(DateTimeOffset.UtcNow.ToUnixTimeSeconds()).Append("}");
queue.Add(e.ToString());
}
void Flush()
{
if (queue.Count == 0) return;
var body = "{\"events\":[" + string.Join(",", queue) + "]}";
queue.Clear();
StartCoroutine(Post(body));
}
IEnumerator Post(string body)
{
using var req = new UnityWebRequest(EventsUrl, "POST");
req.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
req.SetRequestHeader("X-DevNet-Key", Key);
yield return req.SendWebRequest();
}
IEnumerator FetchConfig()
{
using var req = UnityWebRequest.Get(ConfigUrl + "?player=" + PlayerId);
req.SetRequestHeader("X-DevNet-Key", Key);
yield return req.SendWebRequest();
if (req.result == UnityWebRequest.Result.Success) ConfigJson = req.downloadHandler.text; // parse with JsonUtility or your JSON library
}
void OnApplicationQuit()
{
Track("session_end", Math.Round(Time.realtimeSinceStartup - sessionStart));
Flush();
}
}
// Usage: DevNet.Instance.Track("purchase", 4.99, "{\"item\":\"starter_pack\",\"currency\":\"usd\"}");Add the component to an object in your first scene. Works in builds and WebGL.
Source/YourGame/DevNet.h
// DevNet LiveOps for Unreal Engine 5. A small helper you can call from C++ or wrap for Blueprints.
#pragma once
#include "CoreMinimal.h"
#include "HttpModule.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
class FDevNet
{
public:
static constexpr const TCHAR* Key = TEXT("dn_live_your_key_here");
static constexpr const TCHAR* EventsUrl = TEXT("https://devnet.lol/api/liveops/v1/events");
static constexpr const TCHAR* ConfigUrl = TEXT("https://devnet.lol/api/liveops/v1/config");
// Sends one event. Batch on a timer if you send many per second.
static void Track(const FString& Name, const FString& PlayerId, TOptional<double> Value = {}, const FString& PropsJson = TEXT(""))
{
FString Event = FString::Printf(TEXT("{\"name\":\"%s\",\"player\":\"%s\",\"time\":%lld"), *Name, *PlayerId, FDateTime::UtcNow().ToUnixTimestamp());
if (Value.IsSet()) Event += FString::Printf(TEXT(",\"value\":%f"), Value.GetValue());
if (!PropsJson.IsEmpty()) Event += TEXT(",\"props\":") + PropsJson;
Event += TEXT("}");
auto Request = FHttpModule::Get().CreateRequest();
Request->SetURL(EventsUrl);
Request->SetVerb(TEXT("POST"));
Request->SetHeader(TEXT("Content-Type"), TEXT("application/json"));
Request->SetHeader(TEXT("X-DevNet-Key"), Key);
Request->SetContentAsString(TEXT("{\"events\":[") + Event + TEXT("]}"));
Request->ProcessRequest();
}
// Fetches remote config with the player's A/B variants; OnDone gets the raw JSON.
static void FetchConfig(const FString& PlayerId, TFunction<void(const FString&)> OnDone)
{
auto Request = FHttpModule::Get().CreateRequest();
Request->SetURL(FString(ConfigUrl) + TEXT("?player=") + PlayerId);
Request->SetHeader(TEXT("X-DevNet-Key"), Key);
Request->OnProcessRequestComplete().BindLambda([OnDone](FHttpRequestPtr, FHttpResponsePtr Res, bool bOk)
{
if (bOk && Res.IsValid() && Res->GetResponseCode() == 200) OnDone(Res->GetContentAsString());
});
Request->ProcessRequest();
}
};
// Usage: FDevNet::Track(TEXT("session_start"), PlayerState->GetUniqueId().ToString());Add "HTTP" and "Json" to PublicDependencyModuleNames in YourGame.Build.cs. Call Track() from your GameMode or GameInstance.
res://devnet.gd (Autoload as DevNet)
# DevNet LiveOps for Godot 4. Add as an Autoload named "DevNet".
extends Node
const KEY := "dn_live_your_key_here"
const EVENTS_URL := "https://devnet.lol/api/liveops/v1/events"
const CONFIG_URL := "https://devnet.lol/api/liveops/v1/config"
var player_id := ""
var config := {}
var experiments := {}
var _queue: Array = []
var _started := 0
func _ready() -> void:
var f := ConfigFile.new()
f.load("user://devnet.cfg")
player_id = f.get_value("devnet", "player", "")
if player_id == "":
player_id = str(randi()) + str(Time.get_unix_time_from_system())
f.set_value("devnet", "player", player_id)
f.save("user://devnet.cfg")
_started = Time.get_ticks_msec()
track("session_start")
fetch_config()
var timer := Timer.new()
timer.wait_time = 15.0
timer.autostart = true
timer.timeout.connect(flush)
add_child(timer)
func track(name: String, value = null, props := {}) -> void:
var e := { "name": name, "player": player_id, "time": int(Time.get_unix_time_from_system()) }
if value != null: e["value"] = value
if not props.is_empty(): e["props"] = props
_queue.append(e)
func flush() -> void:
if _queue.is_empty(): return
var body := JSON.stringify({ "events": _queue })
_queue = []
var http := HTTPRequest.new()
add_child(http)
http.request_completed.connect(func(_r, _c, _h, _b): http.queue_free())
http.request(EVENTS_URL, ["Content-Type: application/json", "X-DevNet-Key: " + KEY], HTTPClient.METHOD_POST, body)
func fetch_config() -> void:
var http := HTTPRequest.new()
add_child(http)
http.request_completed.connect(func(_r, code, _h, body):
if code == 200:
var data = JSON.parse_string(body.get_string_from_utf8())
config = data.get("config", {})
experiments = data.get("experiments", {})
http.queue_free())
http.request(CONFIG_URL + "?player=" + player_id, ["X-DevNet-Key: " + KEY])
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_CLOSE_REQUEST:
track("session_end", (Time.get_ticks_msec() - _started) / 1000)
flush()
# Usage: DevNet.track("level_up", 5)Add it under Project Settings › Globals › Autoload with the name DevNet. Godot 4.
src/main/java/…/DevNetTracker.java
// DevNet LiveOps for Minecraft servers (Paper/Spigot). Call new DevNetTracker(this) in onEnable().
import org.bukkit.Bukkit;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.java.JavaPlugin;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;
public class DevNetTracker implements Listener {
private static final String KEY = "dn_live_your_key_here";
private static final String EVENTS_URL = "https://devnet.lol/api/liveops/v1/events";
private final HttpClient http = HttpClient.newHttpClient();
private final Queue<String> queue = new ConcurrentLinkedQueue<>();
private final Map<UUID, Long> joined = new HashMap<>();
public DevNetTracker(JavaPlugin plugin) {
Bukkit.getPluginManager().registerEvents(this, plugin);
// Send every 15 seconds, off the main thread
Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::flush, 300L, 300L);
}
public void track(UUID player, String name, Double value) {
String v = value == null ? "" : ",\"value\":" + value;
queue.add("{\"name\":\"" + name + "\",\"player\":\"" + player + "\"" + v + ",\"time\":" + (System.currentTimeMillis() / 1000) + "}");
}
@EventHandler public void onJoin(PlayerJoinEvent e) {
joined.put(e.getPlayer().getUniqueId(), System.currentTimeMillis());
track(e.getPlayer().getUniqueId(), "session_start", null);
}
@EventHandler public void onQuit(PlayerQuitEvent e) {
Long start = joined.remove(e.getPlayer().getUniqueId());
track(e.getPlayer().getUniqueId(), "session_end", start == null ? null : (System.currentTimeMillis() - start) / 1000.0);
}
public void flush() {
List<String> batch = new ArrayList<>();
for (String s; batch.size() < 100 && (s = queue.poll()) != null; ) batch.add(s);
if (batch.isEmpty()) return;
HttpRequest req = HttpRequest.newBuilder(URI.create(EVENTS_URL))
.header("Content-Type", "application/json").header("X-DevNet-Key", KEY)
.POST(HttpRequest.BodyPublishers.ofString("{\"events\":[" + String.join(",", batch) + "]}")).build();
http.sendAsync(req, HttpResponse.BodyHandlers.discarding());
}
}Works on Paper and Spigot 1.18+ (Java 17). Register it in onEnable: new DevNetTracker(this). Players are identified by UUID.
lua/autorun/server/devnet.lua
-- DevNet LiveOps for Garry's Mod. Save as lua/autorun/server/devnet.lua
local KEY = "dn_live_your_key_here"
local EVENTS_URL = "https://devnet.lol/api/liveops/v1/events"
local queue, joined = {}, {}
DevNet = {}
function DevNet.Track(ply, name, value, props)
table.insert(queue, { name = name, player = IsValid(ply) and ply:SteamID64() or tostring(ply), value = value, props = props, time = os.time() })
end
local function flush()
if #queue == 0 then return end
local body = util.TableToJSON({ events = queue })
queue = {}
HTTP({ url = EVENTS_URL, method = "POST", type = "application/json", body = body, headers = { ["X-DevNet-Key"] = KEY } })
end
hook.Add("PlayerInitialSpawn", "DevNetJoin", function(ply)
joined[ply] = CurTime()
DevNet.Track(ply, "session_start")
end)
hook.Add("PlayerDisconnected", "DevNetLeave", function(ply)
DevNet.Track(ply, "session_end", math.floor(CurTime() - (joined[ply] or CurTime())))
joined[ply] = nil
end)
hook.Add("ShutDown", "DevNetFlush", flush)
timer.Create("DevNetFlush", 15, 0, flush)Server-side only. Uses the built-in HTTP function; players are identified by SteamID64.
devnet.js
// DevNet LiveOps for Discord bots (Node.js 18+, no dependencies).
const KEY = "dn_live_your_key_here";
const EVENTS_URL = "https://devnet.lol/api/liveops/v1/events";
const CONFIG_URL = "https://devnet.lol/api/liveops/v1/config";
let queue = [];
export function track(userId, name, value, props) {
queue.push({ name, player: String(userId), value, props, time: Math.floor(Date.now() / 1000) });
}
export async function getConfig(userId) {
const res = await fetch(`${CONFIG_URL}?player=${userId ?? ""}`, { headers: { "X-DevNet-Key": KEY } });
return res.ok ? res.json() : { config: {}, experiments: {} };
}
setInterval(async () => {
if (!queue.length) return;
const events = queue.splice(0, 100);
await fetch(EVENTS_URL, {
method: "POST",
headers: { "Content-Type": "application/json", "X-DevNet-Key": KEY },
body: JSON.stringify({ events }),
}).catch(() => queue.unshift(...events));
}, 15_000);
// Usage, e.g. in your interaction handler:
// track(interaction.user.id, "command", null, { name: interaction.commandName });For discord.js or any Node bot. Track commands as events; each user is a player.
Terminal
# Send events (up to 100 per request; batch every 10–30 seconds)
curl -X POST https://devnet.lol/api/liveops/v1/events \
-H "Content-Type: application/json" \
-H "X-DevNet-Key: dn_live_your_key_here" \
-d '{"events":[{"name":"session_start","player":"player-123"},{"name":"purchase","player":"player-123","value":4.99,"props":{"item":"starter_pack","currency":"usd"}}]}'
# Read remote config, with this player's A/B test variants applied
curl "https://devnet.lol/api/liveops/v1/config?player=player-123" -H "X-DevNet-Key: dn_live_your_key_here"
# Event fields: name (required), player (required), value, session, props (JSON object), time (unix seconds)
# Standard events: session_start, session_end (value = seconds), purchase (value = price), level_up (value = level)Anything that can make an HTTPS request can use LiveOps, including Hytale servers and custom engines.
Fortnite (UEFN)
Verse can't make web requests, so there's no SDK for Fortnite islands. LiveOps reads their numbers from Epic's public island data instead. See Fortnite islands.