← API Reference

Analytics

POST
https://api.audioscape.ai/analytics/v1/analytics

On 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

NameTypeDescription
x-api-keyrequiredYour API key
Content-TyperequiredMust be application/json
X-Universe-IdoptionalRoblox Universe ID. Sent automatically by the SDK; useful when partitioning analytics across multiple experiences on a single key.
X-Place-IdoptionalRoblox 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).

event.json
{
  "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"
  }
}
FieldTypeDescription
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.

TypeRequired FieldsWhen & What
playtype, asset_idFire when a track starts playing. duration is the full track length at playback start (seconds), not how long the player has listened.
stoptype, asset_idFire 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.
skiptype, asset_idFire 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".
votetype, asset_id, valueUser-issued thumbs up/down. value must be "up" or "down".
favoritetype, asset_idUser saved the track to a favorites/likes list.
unfavoritetype, asset_idUser removed the track from favorites. Send the inverse — don't silently delete the prior favorite.
add_to_queuetype, asset_idUser queued the track (jukebox, setlist, "play next"). Stronger intent than a search click, weaker than a play.
search_clicktype, asset_idUser 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.

202 Accepted
{
  "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.

  1. Player searches and clicks the second result — search_click with the query and rank.
  2. Player adds it to their queue — add_to_queue.
  3. Track A starts — play, duration: 217 (full track length).
  4. Player skips after 12s — skip, duration: 12 (listened time).
  5. Track B starts — play, duration: 195.
  6. Mid-listen they hit favorite — favorite.
  7. And vote it up — vote, value: "up".
  8. Track B ends naturally — stop, duration: 195 (full listen).
POST body
{
  "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.

POST /v1/analytics
-- 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)
end

Sending 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:

BehaviorDefaultWhy
Buffer client-sidein-memory queueTrack callers stay synchronous and cheap. The producer never blocks on the network.
Periodic flushevery 30sOne 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 sizeup to 50 / requestHard 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 queue500 events, drop oldestIf 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 backoff2× per failure, capped at 300sOn a sustained outage, back off rather than retry-storming. Reset to the steady-state interval as soon as a flush succeeds.
Retry policynetwork · 429 · 5xxRe-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 shutdownbest-effort, ~3 attemptsHook 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.

Luau (Roblox)
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 Request

The batch envelope is malformed: missing or empty events array, more than 500 events, or invalid JSON.

400.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:

202 Accepted (partial)
{
  "accepted": 3,
  "rejected": 1,
  "errors": [
    "events[2]: vote events require value \"up\" or \"down\""
  ]
}
401Unauthorized

Missing or invalid x-api-key header. Confirm your key on the API Keys page.

401.json
{
  "error": "Unauthorized"
}
429Too Many Requests

Rate-limit exceeded. Re-queue the batch and back off — see the retry policy in Sending at Scale.

429.json
{
  "message": "Too Many Requests"
}
500Internal Server Error

Something 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