API Reference

Everything the browser SDK, the server SDK, and the HTTP API expose.

Browser SDK

<script src="https://indie.fun/js/indie.js"></script>, defines the Indie global. From npm: import { IndieClient } from 'indie-sdk/browser'.

Setup

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.

Errors

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.

Player

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.

Ruby

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.

Scores

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.

Progression

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.

Diagnostics

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.

Consent

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.

Server SDK

npm install indie-sdk · const { Indie } = require('indie-sdk/server'). Needs your App Secret, so it belongs on a server you control.

Setup

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.

Errors

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.

Events

indie.on('playerJoin', cb)A player signed in. The Player object arrives with data and permissions already loaded.

Player data

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.

Ruby

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.

Scores

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.

Progression

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.

Auth

indie.verifyToken(token)Verifies a player token and returns its claims, or null if invalid or expired.

HTTP API

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

Auth

POST /api/auth/verify-tokenApp secret. Body { token }. Returns { valid, userId, name, email, discordId, solanaWallet } or 401.

Player data

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.

Sessions and presence (sent for you by the browser SDK)

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.

Checking your integration

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.

Errors

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.

Analytics

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.

Ruby

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.

Scores

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.

Progression

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": "..." }
}
Every progression report carries its own 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.

Types

Player (server SDK)

{
  id: string
  name: string
  data: Record<string, unknown>
  highScore: number
  permissions: Record<string, boolean>
}

UserInfo (browser SDK)

{
  id: string
  name: string
  picture?: string
}