Errors

The crashes your players hit and you never see. Captured from the browser and from your game server, grouped one row per bug, and readable as a brief you can hand straight to a coding agent.

Set it up

In the browser there is nothing to set up. Creating the client starts it:

<script src="https://indie.fun/js/indie.js"></script>
<script>
  new Indie({ appId: 'your-app-id' });
</script>

On your game server, pass your App Secret as usual. Errors go with it:

import { Indie } from 'indie-sdk/server';

const indie = new Indie({
  appId: process.env.INDIE_APP_ID,
  appSecret: process.env.INDIE_APP_SECRET,
  errors: { release: process.env.GIT_SHA },  // optional, but tells you which build broke
});
Which side an error came from is decided by how the report authenticated, not by what it claims: an App ID alone is a browser, an App ID and App Secret is your server. A page therefore cannot file a crash as a server one, and your App Secret still never belongs in a browser.

What gets captured

Uncaught exceptions
Anything that reaches window.onerror in the browser, or uncaughtException on your server.
Unhandled promise rejections
A promise that rejects with nobody listening, the failure mode that otherwise leaves nothing at all in a log.
Assets that failed to load
A missing sprite, sound, or script. Browser only, and the most common way a build breaks for players and nobody else.
Anything you report yourself
indie.captureException(err, { level: 4 }) from a catch block, or indie.captureMessage('save file was corrupt') when there is no exception to pass. Both take an optional object of context, which is stored with the error and shown alongside it.
SDK calls that failed
A player-data write your server made that we rejected. A rejected write is a player losing progress, so it belongs in the error list rather than only in a log line nobody reads.

Your process keeps behaving the way it did

The server SDK watches for uncaught errors, it does not take them over. If your code already handles uncaughtException, yours stays in charge. If it doesn't, the SDK reproduces Node's default (print the error, exit 1) once the report is away, or after two seconds, whichever comes first. Turn the whole thing off with errors: { captureUnhandled: false }.

How errors are grouped

One row is one bug, however many times it has happened. Two occurrences group together when they are the same failure in the same place: the exception type, the shape of the message, and the top of the stack.

Ids and counts are factored out of the message
"Save 4831 not found" and "Save 5120 not found" are one bug, not two.
Line numbers are not part of the group
They move on every edit, and a group that splits on every deploy tells you nothing about whether a bug is getting worse. Neither is the host or a bundle hash, so staging and production and yesterday’s build are all one row.
Fixed is not deleted
Mark a bug fixed and it leaves the list. If it happens again it reopens itself and is flagged as a regression, which only works because the row survived the fix. Ignoring is the one status an incoming error will not override.

What we store, and what we drop

Credentials never arrive
Bearer tokens, JWTs, and the values of secret-shaped query parameters (token, apiKey, password, signature…) are stripped in the SDK before the report is sent, and stripped again here before it is stored.
Email addresses are redacted
Anywhere they appear: message, stack, or context.
No new identifiers
A report carries the session and device ids your game already had, and the account when the player is signed in. A player who declined analytics has neither, so their crash is recorded without them rather than being given a fresh identifier by the error path.
Occurrences expire after 30 days
Stack traces and per-player counts cover the last 30 days; the row itself, and how many times it has ever happened, are kept for good. A month-old stack describes a build nobody is running.
Volume is capped
A game stuck in a render loop can throw sixty times a second. Identical errors collapse, a rolling minute is capped (20 reports by default), and a page load stops at 100. A reporter that floods turns one bug into two problems.

Tune any of it, or turn it off:

new Indie({
  appId: 'your-app-id',
  errors: {
    release: 'v1.4.2',       // which build this is
    maxPerMinute: 20,        // rolling ceiling
    captureResources: false, // stop reporting failed asset loads
    beforeSend(event) {      // last look: edit it, or return null to drop it
      if (event.message.includes('ResizeObserver')) return null;
      return event;
    },
  },
});

new Indie({ appId: 'your-app-id', errors: false });  // off entirely

Hand it to a coding agent

Every error can be read as Markdown written for whoever has to fix it, what threw, where, how often, to how many players, the stack, and the context. The Copy for a coding agent button on your game's Errors tab copies exactly what this returns:

# every open error, most recently active first
curl -H "X-App-Id: $INDIE_APP_ID" -H "X-App-Secret: $INDIE_APP_SECRET" \
  "https://indie.fun/api/sdk/errors?status=open&format=markdown"

# one error, with its stack and recent occurrences
curl -H "X-App-Id: $INDIE_APP_ID" -H "X-App-Secret: $INDIE_APP_SECRET" \
  "https://indie.fun/api/sdk/errors?fingerprint=<id>&format=markdown"

# the same data as JSON, for a script
curl -H "X-App-Id: $INDIE_APP_ID" -H "X-App-Secret: $INDIE_APP_SECRET" \
  "https://indie.fun/api/sdk/errors?status=open&runtime=server"
Reading errors needs the App Secret, because the list is an aggregate over everyone who plays your game. Keep that call on your own machine or in CI, never in a browser.

Check it worked

Check it worked

1. Throw something on purpose from your game's console:

indie.captureMessage('hello from my game');
await indie.flushErrors();

2. Ask whether it landed. This reports only on the session in front of you, so it needs no credentials:

await fetch('https://indie.fun/api/sdk/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    appId: indie.getAppId(),
    sessionId: indie.getSessionId(),
  }),
}).then(r => r.json());
// → { errors: { session: 1, lastAt: '…' }, … }

3. Open your game's Errors tab. It's there, with its stack, its context, and a button that copies it for an agent.

If nothing arrives

errors: { session: 0 } means we never received it. In order of likelihood: the App ID is wrong or its keys were deleted; the report is still queued (reports batch for a few seconds, flushErrors() sends them now); the page declined analytics, so there is no session id to look up (getSessionId() returns null, and the error is still recorded, just not against a session); or the request never left the browser, check the console and your Content-Security-Policy.

Reporting is deliberately incapable of breaking your game: every entry point swallows its own failures, so a broken reporter is invisible rather than fatal. That is also why the sandbox and the check above exist. You should not have to take our word for it that reports are arriving.