API Reference
This document describes Questarr’s external software interfaces: the REST API exposed by the Express server, and the real-time events pushed over Socket.io. It complementsdocs/ARCHITECTURE.md (system
actors and data flow) and docs/SECURITY_ASSESSMENT.md
(risk assessment).
Update policy: re-run documentation generation for the affected route
group whenever server/routes.ts changes; manually update the Steam/
PCGamingWiki sections when server/steam-routes.ts or
server/pcgamingwiki-router.ts change.
Overview
- Base URL: all endpoints are served under
/apion the same origin as the app (defaulthttp://localhost:5000). - Format: JSON request/response bodies throughout, except file uploads
(
multipart/form-datafor SSL certificate upload) and the download bundle endpoint (application/zipresponse). - Authentication: a JWT bearer token obtained from
POST /api/auth/login(orPOST /api/auth/setupfor the first account), sent asAuthorization: Bearer <token>on subsequent requests. Verified byauthenticateToken/optionalAuthenticateTokeninserver/auth.ts. A global gate —app.use("/api", authenticateToken)inserver/routes.ts— requires a valid JWT for every route registered after it; a small set of routes registered earlier remain public (see the Authentication section below). The/api/integrationsubtree additionally accepts a long-lived integration API key (see the Integration API section); every other route, including key management, is JWT-only. - Rate limiting:
generalApiLimiter(600 req/min/IP) applies to all/apiroutes;authRateLimiteradditionally guards login;sensitiveEndpointLimiteradditionally guards write-heavy/sensitive endpoints;igdbRateLimiteradditionally guards IGDB proxy endpoints. Seeserver/middleware.ts.
Authentication
/api/auth/setup and /api/auth/login are the two security-sensitive
endpoints that rely on inline typeof checks plus manual length/if
validation instead of an express-validator chain or a Zod schema (see the
risk register in docs/SECURITY_ASSESSMENT.md). /api/auth/password uses a
proper Zod schema (updatePasswordSchema).
Routes registered before the global authenticateToken gate (server/routes.ts,
before the auth-gate line) are public unless they explicitly list
authenticateToken: /api/auth/*, /api/health, /api/settings/ssl
(GET/PATCH), /api/settings/ssl/generate, /api/settings/ssl/upload,
/api/system/filesystem, /api/config. Everything registered after that
line — including /api/ready and every resource group below — requires a
JWT even where a table row doesn’t repeat authenticateToken.
Games
Downloads
Downloaders
Root Folders / Library Scan
Extra directories scanned (read-only discovery) for games already on disk outside the configured library root (e.g. an older library, a secondary drive). Separate from the library root used by the download-import pipeline — matched games get alibraryPath outside that root. Discovery itself never moves, renames, or deletes files there; the game-delete flow (DELETE /api/games/:id?deleteFiles=true) only touches files under a root folder when that folder’s allowDelete is explicitly turned on (off by default).
Indexers
IGDB
Settings
RSS
Notifications
Error Telemetry
Backs the consent flow opened from an “error-detected” notification’s link (error-report:<reportId>). Reports are built server-side from recent,
PII-scrubbed server.log lines when an unhandled server error is detected
(uncaught exception, unhandled rejection, or a 5xx response); see
server/error-telemetry.ts. Pending reports are held in memory only (not
persisted), scoped to the user they were created for, and expire after 24h.
reportId must be a UUID (validated via express-validator). For GET, a report
owned by another user is indistinguishable from an unknown/expired one — both
return 404 — so a caller can’t probe for the existence of another user’s
report. POST folds the same “not available” cases (unknown, expired, or not
owned) into the 422 it already uses for delivery failures, since
sendPendingReport doesn’t distinguish them from the caller’s perspective.
Whether an error triggers a notification at all is controlled by the
errorDetected notification preference; whether it’s reported automatically
(skipping the consent step above) is controlled by the per-user
telemetryEnabled setting (Settings > System > Telemetry, off by default).
xREL
PATCH /api/settings/xrel (xREL API base URL and scene/P2P release
preferences) is documented under Settings.
NexusMods
NexusMods API-key configuration (
GET/POST /api/settings/nexusmods) is
documented under Settings.
Stats
Health / Ready / Config
Dashboard Status
HowLongToBeat
Blacklist
Per-game blacklist management (
POST/GET/DELETE /api/games/:gameId/blacklist*)
is documented under Games.
Steam
server/steam-routes.ts — 2 routes, mounted directly on the app (no prefix
router), both behind the global auth gate plus explicit authenticateToken.
PCGamingWiki
server/pcgamingwiki-router.ts — 1 route.
Integration API (external clients)
server/routes/integration.ts — 4 routes, mounted at /api/integration.
This is the one part of the API that accepts a long-lived integration API
key in addition to a JWT, for machine clients that cannot run the interactive
login flow — the Playnite extension,
scripts, other self-hosted tools. The key is presented as X-Api-Key: <key> or
Authorization: Bearer <key> (keys are recognised by their qsr_ prefix, so a
Bearer header is unambiguous between a key and a JWT).
Authentication is handled by authenticateApiKeyOrToken in server/auth.ts,
selected by requireAuthenticationForApi for paths under /integration only.
Everywhere else on /api — key management included — stays JWT-only, so a
leaked key cannot escalate into minting or revoking other keys.
IntegrationGame is a deliberately narrow projection of a library game —
id, title, igdbId, steamAppId, status, releaseStatus, releaseDate,
coverUrl, platforms, genres, libraryPath, addedAt. Internal fields
(userId, notes, search-result bookkeeping) are never exposed.
apiVersion (INTEGRATION_API_VERSION) describes the integration contract
itself and is bumped only when a change would break an already-released client,
so an extension can fail loudly on a mismatch instead of misbehaving mid-sync.
Sync matching: an incoming entry is matched against the caller’s library by
Steam App ID first, then by normalized title (normalizeTitle from
shared/title-utils.ts). Unmatched entries are reported back rather than
added — a sync never creates library entries on its own. markInstalledAsOwned
promotes a matched game from wanted to owned only when the client reports it
installed, and is off by default.
Requesting a game: a requested game is added with status wanted, which is
what hands it to the existing auto-search pipeline (checkAutoSearch in
server/cron.ts) — that is how a request from the couch turns into a download.
API Keys (management)
server/routes/api-keys.ts — 3 routes, mounted at /api/api-keys, JWT only.
Keys are stored as a SHA-256 hash plus a short display prefix; the raw key is
returned exactly once, in the 201 response that creates it, and is not
recoverable afterwards. A user may hold at most 25 keys.
Error Format
Across handlers, error responses follow one of these consistent shapes:- Validation failures (express-validator chains): the shared
validateRequestmiddleware (server/middleware.ts) runs after anysanitize*validator array. On failure it returns HTTP 400 with: - Validation failures (Zod schemas): handlers that call
someSchema.parse(req.body)catchz.ZodErrordirectly and return HTTP 400 with:(message text varies per handler, e.g. “Invalid game data”, “Invalid status data”, “Invalid settings data”) - Ad hoc business-logic errors: most handlers return a plain
{ "error": "<message>" }object directly for 400/401/403/404/409/502 cases (sometimes with an extra field attached, e.g.{ error, game }on a duplicate-game 409), without going through shared middleware. - Uncaught/rethrown errors (global handler): routes that call
next(error)fall through toerrorHandler(server/middleware.ts), which returns:Status comes fromerr.status/err.statusCode, defaulting to 500. In production, 5xx messages are sanitized to"Internal Server Error"to avoid leaking internals; 4xx messages pass through as-is. All errors are logged via Pino with method/path context —errorlevel for 5xx,warnotherwise. - No-content success: several
DELETEendpoints return204 No Contentwith an empty body rather than a JSON payload (e.g.DELETE /api/games/:id,DELETE /api/indexers/:id,DELETE /api/downloaders/:id,DELETE /api/rss/feeds/:id,DELETE /api/notifications).
Real-time interface (Socket.io)
Alongside the REST API, the server pushes real-time events over Socket.io (server/socket.ts). See docs/ARCHITECTURE.md §6
for the full actor/data-flow explanation. Summary:
Both events are broadcast to every connected socket (
io.emit) — there are
no per-user rooms yet.