Analytics
POSThttps://api.audioscape.ai/analytics/v1/analyticsOn this pageEndpoint▼
Endpoint
Stream playback and engagement events back to AudioScape. Events feed catalog popularity scoring, search ranking, and the dashboards on your Analytics tab.
Events are submitted in batches of up to 500. The endpoint accepts the batch, persists it, and returns 202 Accepted — aggregation happens out-of-band and surfaces in the dashboard within a day. Don't expect synchronous reads: this is a write-only ingestion endpoint.
Headers
| Name | Type | Description |
|---|---|---|
| x-api-key | required | Your API key |
| Content-Type | required | Must be application/json |
| X-Universe-Id | optional | Roblox Universe ID. Sent automatically by the SDK; useful when partitioning analytics across multiple experiences on a single key. |
| X-Place-Id | optional | Roblox Place ID |
End-user identity travels per-event in player_id, not as a header — a single batch can carry events from many players.
Request Body
A single field, events: an array of 1-500 event objects. Each event matches the shape below. Per-event validation is independent — a malformed entry rejects only itself, not the whole batch (see Response).
{
"type": "string", // event type (built-in or custom)
"asset_id": "string", // required for built-in types
"timestamp": number, // optional, Unix seconds; defaults to receive time
"player_id": "string", // optional, your end-user identifier
"duration": number, // optional, seconds; semantics depend on type
"value": "up" | "down", // required for vote events
"metadata": { // optional, max 10 keys, scalar values only
"key": "string | number | boolean"
}
}| Field | Type | Description |
|---|---|---|
| type | string required | One of the eight built-in types (see Event Types), or any custom string. Custom types are accepted and stored, but only built-ins feed scoring and dashboards. |
| asset_id | string required for built-ins | The Roblox asset ID the event refers to (numeric string, e.g. "1843209165"). Optional for custom event types that aren't track-scoped. |
| timestamp | number optional | Unix epoch in seconds. When omitted, the server stamps it on receipt — this is wrong for buffered events, so set it at emission time. Must fall within the last 7 days; up to 5 minutes in the future is tolerated for clock skew. |
| player_id | string optional | Stable end-user identifier. For Roblox, pass the player's UserId as a string. Truncated to 256 characters server-side. |
| duration | number optional | Seconds, non-negative. Semantics depend on the event type — see the gotcha in Event Types before sending. |
| value | string vote only | Required for vote events. Must be "up" or "down". Ignored on other types. |
| metadata | object optional | Free-form context bag. Up to 10 keys; values must be string, number, or boolean (no nested objects, arrays, or null). Useful for search_click rank/query, or anything you'd want to slice on later in custom event analysis. |
Event Types
Eight built-in event types cover the full playback lifecycle. The duration field changes meaning across play / stop / skip — read this table before wiring it up.
| Type | Required Fields | When & What |
|---|---|---|
play | type, asset_id | Fire when a track starts playing. duration is the full track length at playback start (seconds), not how long the player has listened. |
stop | type, asset_id | Fire when a track ends naturally (audio finished, queue moved on, player left the experience). duration is the actual seconds listened — not the track length. For a fully-played 195s track this is ~195; for a player who left after 40s it's 40. |
skip | type, asset_id | Fire when the user actively skips. Distinct from stop: skips are a negative signal for ranking, natural ends are positive. duration is again the actual seconds listened before the skip — useful for distinguishing "skipped at 0:03" from "skipped at 2:45". |
vote | type, asset_id, value | User-issued thumbs up/down. value must be "up" or "down". |
favorite | type, asset_id | User saved the track to a favorites/likes list. |
unfavorite | type, asset_id | User removed the track from favorites. Send the inverse — don't silently delete the prior favorite. |
add_to_queue | type, asset_id | User queued the track (jukebox, setlist, "play next"). Stronger intent than a search click, weaker than a play. |
search_click | type, asset_id | User clicked a result on a search results screen. Use metadata to record the originating query and rank — those signals improve ranking quality. |
Custom Events
Anything outside the eight built-ins is treated as a custom event. The type field becomes the event name, asset_id is optional, and the rest of the schema is unchanged. Custom events are stored alongside built-ins but don't feed the catalog scoring pipeline — they're for your own analysis.
Response
On success, returns 202 Accepted with the count of stored events. If some events failed validation but others succeeded, the rejected count and per-event error messages are included so you can fix client-side bugs without losing the rest of the batch.
{
"accepted": number, // events that passed validation
"rejected": number, // omitted when 0
"errors": ["string"] // omitted when 0; one entry per rejected event
}If every event in the batch fails validation, the response is 400 Bad Request with an error + details array instead — see Errors.
Anatomy of a Session
A complete arc for one player across two tracks. The order shows the relative timing of each event (timestamps a few seconds apart); in practice you'd buffer these and submit them as one batch every ~30 seconds rather than one POST per event.
- Player searches and clicks the second result —
search_clickwith the query and rank. - Player adds it to their queue —
add_to_queue. - Track A starts —
play,duration: 217(full track length). - Player skips after 12s —
skip,duration: 12(listened time). - Track B starts —
play,duration: 195. - Mid-listen they hit favorite —
favorite. - And vote it up —
vote,value: "up". - Track B ends naturally —
stop,duration: 195(full listen).
{
"events": [
{
"type": "search_click",
"asset_id": "1843209165",
"player_id": "5849290482",
"timestamp": 1746460800,
"metadata": { "query": "lofi study beats", "rank": 2 }
},
{
"type": "add_to_queue",
"asset_id": "1843209165",
"player_id": "5849290482",
"timestamp": 1746460803
},
{
"type": "play",
"asset_id": "1843209165",
"player_id": "5849290482",
"timestamp": 1746460810,
"duration": 217
},
{
"type": "skip",
"asset_id": "1843209165",
"player_id": "5849290482",
"timestamp": 1746460822,
"duration": 12
},
{
"type": "play",
"asset_id": "9182734652",
"player_id": "5849290482",
"timestamp": 1746460823,
"duration": 195
},
{
"type": "favorite",
"asset_id": "9182734652",
"player_id": "5849290482",
"timestamp": 1746460905
},
{
"type": "vote",
"asset_id": "9182734652",
"player_id": "5849290482",
"timestamp": 1746460912,
"value": "up"
},
{
"type": "stop",
"asset_id": "9182734652",
"player_id": "5849290482",
"timestamp": 1746461018,
"duration": 195
}
]
}Example Request
The bare-minimum direct integration from Luau, using HttpService:RequestAsync — no SDK, no buffering, just enough to verify the contract end-to-end. Wrap this in the buffer-and-flush pattern from Sending at Scale before shipping.
-- Direct HTTP — no SDK. Posts a single play+stop pair.
local HttpService = game:GetService("HttpService")
local response = HttpService:RequestAsync({
Url = "https://api.audioscape.ai/analytics/v1/analytics",
Method = "POST",
Headers = {
["x-api-key"] = "your-api-key-here",
["Content-Type"] = "application/json",
},
Body = HttpService:JSONEncode({
events = {
{ type = "play", asset_id = "1843209165", player_id = "5849290482", duration = 217 },
{ type = "stop", asset_id = "1843209165", player_id = "5849290482", duration = 217 },
},
}),
})
if not response.Success then
warn("Analytics POST failed: " .. response.StatusCode .. " " .. response.Body)
endSending Events at Scale
Don't POST per event. Track-scoped events fire constantly during playback and a synchronous request per event will hammer the endpoint, blow your rate limit, and starve the rest of your application of network bandwidth — particularly on Roblox, where every game server shares one tight HttpService request budget with the rest of your game logic.
The Roblox SDK ships with the right transport behavior built in. If you're calling the API directly from your own runtime, mirror this pattern:
| Behavior | Default | Why |
|---|---|---|
| Buffer client-side | in-memory queue | Track callers stay synchronous and cheap. The producer never blocks on the network. |
| Periodic flush | every 30s | One request per flush instead of one per event. Tune lower for snappier dashboards, higher for tighter HTTP budgets — but don’t drop below 5 seconds. |
| Batch size | up to 50 / request | Hard cap is 500 per request; 50 keeps individual payloads small (~10 KB) so they fit comfortably under platform request size limits and re-send fast on retry. |
| Bounded queue | 500 events, drop oldest | If the server is unreachable for a long time, recent events are more valuable than ancient ones. Capping memory protects the host process from unbounded growth. |
| Exponential backoff | 2× per failure, capped at 300s | On a sustained outage, back off rather than retry-storming. Reset to the steady-state interval as soon as a flush succeeds. |
| Retry policy | network · 429 · 5xx | Re-queue the batch on transport errors, 429 Too Many Requests, and 5xx. Drop on other 4xx responses — those are bugs in your payload that won't fix themselves on retry. |
| Flush on shutdown | best-effort, ~3 attempts | Hook your runtime's shutdown signal — Roblox game:BindToClose, browser visibilitychange, Node SIGTERM — and drain the buffer synchronously. Cap the attempts so a hung endpoint can't hold up shutdown. |
Roblox SDK
The Luau SDK ships everything from Sending at Scale out of the box: the buffer, the periodic flush, exponential backoff, the retry policy, the bounded queue, and a BindToClose drain on game shutdown. Track callers are fire-and-forget. The optional client:createPlayer() wrapper also auto-fires play / stop / skip with the right durations across the queue lifecycle, so you don't have to wire those by hand.
local AudioScape = require(ServerStorage.AudioScape)
local client = AudioScape.new("your-api-key")
-- Defaults: 30s flush, batches of 50, queue cap 500.
-- Override only if you have a reason to:
client:configureAnalytics({
batchInterval = 30,
maxBatchSize = 50,
maxQueueSize = 500,
})
-- Manual events — fire and forget, the SDK buffers and flushes:
client:trackPlay(track.asset_id, player.UserId, track.duration)
client:trackStop(track.asset_id, player.UserId, listenedSeconds)
client:trackVote(track.asset_id, "up", player.UserId)
-- Or let the player do it for you. createPlayer() auto-emits
-- play/stop/skip with correct durations across the queue lifecycle:
local musicPlayer = client:createPlayer({ playerId = player.UserId })
musicPlayer:queue(searchResult.tracks)
musicPlayer:play()Error Responses
400Bad RequestThe batch envelope is malformed: missing or empty events array, more than 500 events, or invalid JSON.
{
"error": "Request body must contain an \"events\" array"
}Or — if some events passed and others failed validation, you get a 202 with the rejected count and per-event error messages so you can fix client-side bugs:
{
"accepted": 3,
"rejected": 1,
"errors": [
"events[2]: vote events require value \"up\" or \"down\""
]
}401UnauthorizedMissing or invalid x-api-key header. Confirm your key on the API Keys page.
{
"error": "Unauthorized"
}429Too Many RequestsRate-limit exceeded. Re-queue the batch and back off — see the retry policy in Sending at Scale.
{
"message": "Too Many Requests"
}500Internal Server ErrorSomething went wrong on our end. Re-queue and retry with backoff; if it persists, ping us on Discord.
Ready to instrument your player?
Drop in the SDK or wire it up directly — then watch your dashboards fill in.
Open Analytics Dashboard