Everything the browser SDK, the server SDK, and the HTTP API expose.
<script src="https://strafe.fun/js/strafe.js"></script>, defines the Strafe global. From npm: import { StrafeClient } from '@strafe-fun/sdk/browser'.
new Strafe({ appId })Creates the client: mounts the login widget and starts measuring sessions.new Strafe({ 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 Strafe({ appId, consent })'auto' (default) shows the guest-analytics bar; 'manual' hands consent to your own UI; 'off' disables the region gate.new Strafe({ 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 Strafe({ appId, errors })Crash reporting, on by default. false turns it off; { release, maxPerMinute, maxTotal, captureUnhandled, captureResources, beforeSend } tunes it.new Strafe({ 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.strafe.captureException(err, context?)Report an error you caught yourself. context is a small object of scalars, stored with it.strafe.captureMessage(text, context?)Report a problem that isn't an exception.strafe.flushErrors()Promise<void>. Sends what's queued now instead of waiting for the batch.strafe.getUser(){ id, name, picture } for the signed-in player, or null.strafe.getToken()The player's JWT, or null. Send it to your server to verify.strafe.onChange(cb)Fires immediately with { user, token }, then on every login and logout.strafe.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.strafe.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.strafe.onRubyChange(cb)Called with { balance, rewards } now and on every change. rewards is any promotion strafe.fun has just paid this player, the moment to show '+1 Ruby'.There is no way to give Rubies from the browser. See Ruby.
strafe.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.
strafe.getAppId()The app id this client reports to.strafe.getSessionId()The session being measured, or null when this visit isn't tracked (consent declined, or Do-Not-Track).strafe.getDeviceId()The persistent guest device id, or null when guest tracking isn't permitted.strafe.destroy()Flushes the session, stops the heartbeats and removes the widget. For an SPA unmounting the client, a normal page doesn't need it.strafe.optIn()Allow anonymous analytics for this visitor and start tracking.strafe.optOut()Decline or withdraw. Stops guest tracking and drops the device id.strafe.getConsent()'granted' | 'denied' | 'unknown'. Do-Not-Track and Global Privacy Control report 'denied'.See Analytics for what each mode does where.
npm install @strafe-fun/sdk · const { Strafe } = require('@strafe-fun/sdk/server'). Needs your App Secret, so it belongs on a server you control.
new Strafe({ appId, appSecret })Connects over WebSocket and starts receiving player events. Reconnects with backoff; a bad secret closes with code 4003.new Strafe({ appId, appSecret, errors })Crash reporting, on by default. Watches uncaught errors without changing how your process behaves; { captureUnhandled: false } leaves them alone.strafe.close()Stops reconnecting, releases the process listeners, flushes queued reports.strafe.captureException(err, context?)Report an error you caught yourself.strafe.captureMessage(text, context?)Report a problem that isn't an exception.strafe.flushErrors()Promise<void>. Sends what's queued now.strafe.on('playerJoin', cb)A player signed in. The Player object arrives with their saved data already loaded.strafe.getPlayerData(playerId)Promise<object>. Everything saved for that player.strafe.savePlayerData(playerId, data)Promise<void>. Replaces all data.strafe.updatePlayerData(playerId, data)Promise<void>. Merges the given fields.strafe.getRubyTreasury()Promise<{ balance, history } | null>. Your game's own Rubies, and where they went.strafe.getPlayerRubies(playerId)Promise<number | null>. What one of your players holds, across all of strafe.fun.strafe.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.strafe.createCheckout(userId, { item, priceCents, coins, reference })Promise<{ ok, url, order, replay, error }>. Opens one purchase and returns the URL to send the player to. The same reference returns the first checkout rather than charging twice.strafe.getCoinOrder(orderId)Promise<order | null>. One purchase, whatever state it is in. What to call when a player returns from checkout, instead of trusting their word.strafe.listCoinOrders({ since })Promise<order[]>. Every paid purchase after that instant, oldest first. Resolves [] on failure, so a cursor never advances past something it did not see.strafe.progress(playerId, step)Promise<void>. Records a milestone your server vouches for. step is a name or an array of them.strafe.getProgressionReport({ days, steps, population })Promise<report | null>. The funnel as JSON: players per step, drop-off, and time to reach.strafe.verifyToken(token)Verifies a player token and returns its claims, or null if invalid or expired.Base URL https://strafe.fun. 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?playerId=App secret. Returns { data }. A browser may call it for itself with a bearer token and no playerId.PUT /api/player/dataApp secret. Body { playerId, data }. Replaces all data.PATCH /api/player/dataApp secret. Body { playerId, 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://strafe.fun/api/sdk/verify \
-H 'Content-Type: application/json' \
-d '{"appId":"app_...","sessionId":"<from strafe.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: $STRAFE_APP_ID" -H "X-App-Secret: $STRAFE_APP_SECRET" \
"https://strafe.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 strafe.fun, what the counter in the navbar lists.POST /api/sdk/checkoutsApp secret. Body { userId, item, priceCents, coins?, reference?, accept? }. Returns { checkout, url, replay }. priceCents is 25 to 50000; reference is unique per game and makes a retry one purchase; accept lists the currencies this purchase may be paid in and defaults to USDC only.GET /api/sdk/coin-ordersApp secret. ?orderId= is one purchase in any state; ?since=<ISO date> is every paid one after that instant, oldest first, with a cursor to walk forward, plus ?after=<order id> so two paid in the same millisecond cannot straddle a page boundary. Without either, from the oldest.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: $STRAFE_APP_ID" -H "X-App-Secret: $STRAFE_APP_SECRET" \
"https://strafe.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
}{
id: string
name: string
picture?: string
}