Everything the browser SDK, the server SDK, and the HTTP API expose.
<script src="https://indie.fun/js/indie.js"></script>, defines the Indie global. From npm: import { IndieClient } from 'indie-sdk/browser'.
new Indie({ appId })Creates the client: mounts the login widget and starts measuring sessions.new Indie({ appId, apiUrl })Points the client at a different host. Leave unset unless you know you need it. Login only accepts tokens from this origin.new Indie({ appId, login })The built-in login pill, on by default. false leaves it unmounted for an embed that has its own account system; indie.login() still opens the popup, and everything else, analytics included, is unchanged.new Indie({ appId, consent })'auto' (default) shows the guest-analytics bar; 'manual' hands consent to your own UI; 'off' disables the region gate.new Indie({ appId, progression })Your milestones in order, e.g. ['game_start', 'tutorial_done', 'level_1']. Optional, it tells the progression report which sequence to measure.new Indie({ appId, errors })Crash reporting, on by default. false turns it off; { release, maxPerMinute, maxTotal, captureUnhandled, captureResources, beforeSend } tunes it.new Indie({ appId, performance })Frame rate and stalls while the game is played, on by default. Measures nothing about the player, only how their machine coped, and rides on the heartbeat already being sent. false turns it off.indie.captureException(err, context?)Report an error you caught yourself. context is a small object of scalars, stored with it.indie.captureMessage(text, context?)Report a problem that isn't an exception.indie.flushErrors()Promise<void>. Sends what's queued now instead of waiting for the batch.indie.login()Opens the sign-in popup, the same one the pill opens. Call it from a real click, and read the result through onChange.indie.getUser(){ id, name, picture } for the signed-in player, or null.indie.getToken()The player's JWT, or null. Send it to your server to verify.indie.getPermissions()Flat permission map, e.g. { chat: true, vip: false }. Empty until the join response lands.indie.onChange(cb)Fires immediately with { user, token, permissions }, then on every login, logout and permission change.indie.getRubies()Promise<{ balance, promos }>. The signed-in player's Ruby balance, and the promotions your game is running that they can still earn. balance is null when nobody is signed in.indie.spendRubies(amount, { item, key })Promise<{ ok, balance, error }>. Charges the player; the Rubies land in your game's treasury. The same key never charges twice. Requires a token issued for your app.indie.onRubyChange(cb)Called with { balance, rewards } now and on every change. rewards is any promotion indie.fun has just paid this player, the moment to show '+1 Ruby'.There is no way to give Rubies from the browser. See Ruby.
indie.submitScore(score)Reports how well this player is doing. Only their best is kept, and calls are coalesced to one request every few seconds. The number means whatever your game means by it.It ranks your players against each other on your game's leaderboard. See Scores.
indie.progress('level_1')Records that this player reached a milestone. Only their first reach counts toward conversion; calls are batched and flushed again on page close.See Progression for naming steps and reading the funnel.
indie.getAppId()The app id this client reports to.indie.getSessionId()The session being measured, or null when this visit isn't tracked (consent declined, or Do-Not-Track).indie.getDeviceId()The persistent guest device id, or null when guest tracking isn't permitted.indie.destroy()Flushes the session, stops the heartbeats and removes the widget. For an SPA unmounting the client, a normal page doesn't need it.indie.optIn()Allow anonymous analytics for this visitor and start tracking.indie.optOut()Decline or withdraw. Stops guest tracking and drops the device id.indie.getConsent()'granted' | 'denied' | 'unknown'. Do-Not-Track and Global Privacy Control report 'denied'.See Analytics for what each mode does where.
npm install indie-sdk · const { Indie } = require('indie-sdk/server'). Needs your App Secret, so it belongs on a server you control.
new Indie({ appId, appSecret })Connects over WebSocket and starts receiving player events. Reconnects with backoff; a bad secret closes with code 4003.new Indie({ appId, appSecret, errors })Crash reporting, on by default. Watches uncaught errors without changing how your process behaves; { captureUnhandled: false } leaves them alone.indie.close()Stops reconnecting, releases the process listeners, flushes queued reports.indie.captureException(err, context?)Report an error you caught yourself.indie.captureMessage(text, context?)Report a problem that isn't an exception.indie.flushErrors()Promise<void>. Sends what's queued now.indie.on('playerJoin', cb)A player signed in. The Player object arrives with data and permissions already loaded.indie.getPlayerData(playerId)Promise<object>. Everything saved for that player.indie.savePlayerData(playerId, data)Promise<void>. Replaces all data.indie.updatePlayerData(playerId, data)Promise<void>. Merges the given fields.indie.getRubyTreasury()Promise<{ balance, history } | null>. Your game's own Rubies, and where they went.indie.getPlayerRubies(playerId)Promise<number | null>. What one of your players holds, across all of indie.fun.indie.grantRubies(playerId, amount, { key, reason })Promise<{ ok, treasury, error }>. Pays a player out of your treasury. The key makes a retry safe; a treasury that cannot cover it returns ok: false rather than going negative.indie.submitScore(playerId, score)The same record as the browser call, vouched for by your App Secret — for a game whose server is the referee. Only the player's best is kept.indie.progress(playerId, step)Promise<void>. Records a milestone your server vouches for. step is a name or an array of them.indie.getProgressionReport({ days, steps, population })Promise<report | null>. The funnel as JSON: players per step, drop-off, and time to reach.indie.verifyToken(token)Verifies a player token and returns its claims, or null if invalid or expired.Base URL https://indie.fun. Use that host itself: www.indie.fun and modd.io redirect to it, which costs every call a round trip, and a redirect that is ever changed from a 308 to a 301 would turn your POSTs into GETs and drop their bodies (what fetch and curl both do with a 301, by the standard). Server calls authenticate with X-App-Id + X-App-Secret; browser calls with X-App-Id and, where a player is involved, Authorization: Bearer <player token>.
POST /api/auth/verify-tokenApp secret. Body { token }. Returns { valid, userId, name, email, discordId, solanaWallet } or 401.GET /api/player/data?userId=App secret. Returns { data }. A browser may call it for itself with a bearer token and no userId.PUT /api/player/dataApp secret. Body { userId, data }. Replaces all data.PATCH /api/player/dataApp secret. Body { userId, data }. Merges fields.POST /api/player/sessionOpens a session. Body { sessionId, deviceId? }; bearer token optional; a signed-in player's account attaches to the session.POST /api/player/session/heartbeatBody { sessionId, durationSec, deviceId? }. Duration is cumulative and applied with max, so repeats are harmless.POST /api/player/joinBearer. Records the login and returns the player's permission status.POST /api/player/presenceBody { anonId }. Feeds the live player count; DELETE to leave.POST /api/player/heartbeatBearer. Keeps a signed-in player in the live count.POST /api/sdk/verifyPublic. Body { appId, sessionId?, deviceId? }. Optional bearer. Reports only on ids you supply.GET /api/me/creations/[gameId]/integrationYour portal session. The setup checklist for one of your games, over all its players.curl -X POST https://indie.fun/api/sdk/verify \
-H 'Content-Type: application/json' \
-d '{"appId":"app_...","sessionId":"<from indie.getSessionId()>"}'
{
"app": { "id": "app_...", "exists": true, "active": true },
"session": { "sessionId": "...", "date": "2026-07-27", "startedAt": "...",
"durationSec": 63, "identity": "account" },
"device": { "sessions": 4, "firstDate": "2026-07-20", "lastAt": "..." },
"errors": { "session": 1, "lastAt": "..." },
"auth": { "valid": true, "userId": "...", "name": "..." }
}/api/sdk/verify answers only for identifiers you already hold, a session id and a device id are random values minted by your own browser. It never reports another player's session or any total for the app; those need the owner endpoint above.POST /api/sdk/errorsBody { events[], sessionId?, deviceId? }. App ID alone files them as browser errors; App ID + App Secret as server ones. Up to 20 events a request.GET /api/sdk/errorsApp secret. ?status=open|resolved|ignored|all, ?runtime=browser|server, ?days=, ?limit=, ?fingerprint=, ?format=markdown.GET /api/me/creations/[gameId]/errorsYour portal session. The same list plus a summary; ?format=markdown returns the agent brief.PATCH /api/me/creations/[gameId]/errors/[fingerprint]Your portal session. Body { status: 'open' | 'resolved' | 'ignored' }.curl -H "X-App-Id: $INDIE_APP_ID" -H "X-App-Secret: $INDIE_APP_SECRET" \
"https://indie.fun/api/sdk/errors?status=open"
{
"retentionDays": 30,
"errors": [
{ "fingerprint": "a1b2c3d4e5f60718", "runtime": "browser", "type": "TypeError",
"message": "Cannot read properties of undefined (reading 'hp')",
"culprit": "updateHud (/js/main.js:412)", "status": "open", "count": 128,
"firstSeen": "...", "lastSeen": "...", "regressedAt": null, "release": "1.4.2",
"impact": { "players": 37, "sessions": 41, "events": 88 } }
]
}count covers all history; impact covers the last retentionDays, since it is counted from the individual occurrences, which expire. Add &format=markdown to any of these for the brief described in Errors.
GET /api/me/creations/[gameId]/analyticsYour portal session. ?days=7|14|30|90 (default 14), ?population=all|loggedin.{
"windowDays": 14,
"range": { "start": "2026-07-14", "end": "2026-07-27" },
"overview": { "players": 812, "newPlayers": 240, "sessions": 3120, "avgSessionDuration": 412 },
"previous": { ... }, // the equally long window before it
"lifetime": { "players": 9021, "sessions": 41233, "avgSessionDuration": 388 },
"retention": { "d1": 34, "d7": 18, "d14": 12, "d28": 9 }, // -1 = not enough history
"daily": [
{ "date": "2026-07-14", "dau": 61, "newPlayers": 12, "sessions": 143,
"avgDuration": 407, "d1": { "cohort": 12, "returned": 4, "percent": 33 } },
{ "date": "2026-07-27", "dau": 58, "newPlayers": 9, "sessions": 121,
"avgDuration": 431, "d1": null } // null = the cohort's day 1 isn't over yet
]
}daily is dense: every day in the window is present, zeros included. See Analytics for what each figure counts.
GET /api/player/rubyBearer + X-App-Id. { balance, promos } for the signed-in player.POST /api/player/ruby/spendBearer + X-App-Id. Body { amount, key, item? }. The token must have been issued for this app. 409 when the player cannot cover it.GET /api/sdk/rubyApp secret. Your treasury and its history; ?playerId= adds that player's balance.POST /api/sdk/rubyApp secret. Body { playerId, amount, key, reason? }. Pays a player out of your treasury.GET /api/promosPublic. Every Ruby promotion running on indie.fun, what the counter in the navbar lists.POST /api/player/scoreSent for you by the browser SDK's submitScore(). Body { score, deviceId? }; bearer optional. With an app secret instead, body { userId, score } records a score your server vouches for. Only a player's best is kept.POST /api/player/progressSent for you by the browser SDK. Body { steps: [{ step, index?, elapsedMs?, count? }], deviceId? }; bearer optional. With an app secret instead, body { userId, steps } records a milestone your server vouches for.GET /api/reports/progressionApp secret. The funnel as JSON, readable by a script or an agent, with no browser session. ?days=1..365 (default 30), ?steps=a,b,c, ?population=all|loggedin.GET /api/me/creations/[gameId]/progressionYour portal session. The same report for one of your games, what the Dashboard tab draws.curl -H "X-App-Id: $INDIE_APP_ID" -H "X-App-Secret: $INDIE_APP_SECRET" \
"https://indie.fun/api/reports/progression?days=30"
{
"range": { "days": 30, "start": "2026-06-28", "end": "2026-07-27" },
"stepOrder": "declared", // or "requested" / "inferred"
"funnel": [
{ "step": "game_start", "position": 1, "players": 412, "conversionFromEntry": 100,
"conversionFromPrevious": null, "droppedFromPrevious": null, "dropOffRate": null,
"medianSecondsFromEntry": 0, "medianSecondsIntoSession": 4, "reachesPerPlayer": 1.1 },
{ "step": "tutorial_done", "position": 2, "players": 328, "conversionFromEntry": 80,
"conversionFromPrevious": 80, "droppedFromPrevious": 84, "dropOffRate": 20,
"medianSecondsFromEntry": 190, "medianSecondsIntoSession": 194, "reachesPerPlayer": 1 }
],
"summary": { "entered": 412, "completed": 37, "completionRate": 9,
"biggestDrop": { "from": "tutorial_done", "to": "level_1_clear",
"fromPlayers": 328, "lostPlayers": 180, "dropOffRate": 55,
"medianSecondsFromEntry": 190 },
"missingEntryStep": 0, "headline": "Players drop most between ..." },
"unmappedSteps": [], "notes": ["..."], "definitions": { "funnel[].players": "..." }
}definitions and notes, so it can be pasted into a script, or an agent, without these docs alongside it. The notes say which caveats apply to those numbers. See Progression.{
id: string
name: string
data: Record<string, unknown>
highScore: number
permissions: Record<string, boolean>
}{
id: string
name: string
picture?: string
}