Questarr

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 complements docs/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

Authentication

Method Path Auth Required Request Body Response
GET /api/auth/status None (public) — { hasUsers: boolean }
POST /api/auth/setup None (public; blocked once a user exists) { username: string, password: string, igdbClientId?: string, igdbClientSecret?: string } — manual type-checking only, not express-validator/Zod { token, user: { id, username } }; 403 if setup already completed; 400 on invalid input
POST /api/auth/login authRateLimiter (20 req / 15 min) { username: string, password: string } — manual type-checking only, no express-validator/Zod schema { token, user: { id, username } }; 401 on invalid credentials
GET /api/auth/me authenticateToken (JWT) — { id, username, steamId64 }
PATCH /api/auth/password authenticateToken + sensitiveEndpointLimiter { currentPassword: string, newPassword: string } — validated via Zod updatePasswordSchema { success: true, message }; 401 if current password wrong; 400 { error, details } on Zod failure

/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

Method Path Auth Required Request Body Response
GET /api/games JWT (global gate) Query: search?, includeHidden?, status? (comma-separated or array) Game[]
GET /api/games/status/:status JWT Query: includeHidden? Game[]
GET /api/games/search JWT + sanitizeSearchQuery, validateRequest Query: q (1–200 chars, required), search? (≤200), limit? (1–100) Game[]; 400 if q missing
POST /api/games JWT + sensitiveEndpointLimiter, sanitizeGameData, validateRequest { title (1–500), igdbId?, summary? (≤5000), coverUrl? (URL), releaseDate? (YYYY-MM-DD), rating? (0–10), platforms?, genres?, publishers?, developers? }, re-parsed via insertGameSchema (Zod) 201 Game; 409 { error, game } if duplicate; 400 { error, details } on Zod failure
PATCH /api/games/:id/status JWT + sensitiveEndpointLimiter, sanitizeGameId, sanitizeGameStatus, validateRequest { status: "wanted"\|"owned"\|"completed"\|"downloading" } via updateGameStatusSchema Updated Game; 404 if not found
PATCH /api/games/:id/hidden JWT + sensitiveEndpointLimiter, sanitizeGameId, validateRequest { hidden: boolean } via updateGameHiddenSchema Updated Game; 404 if not found
PATCH /api/games/:id/user-rating JWT + sensitiveEndpointLimiter, sanitizeGameId, validateRequest { userRating: number\|null } (0.5–10, step 0.5) via updateGameUserRatingSchema Updated Game; 404 if not found
PATCH /api/games/:id/installed-version JWT + sensitiveEndpointLimiter, sanitizeGameId, validateRequest { installedVersion: string\|null } (free text, trimmed, ≤64; blank clears) via updateGameInstalledVersionSchema Updated Game; 404 if not found. Auto-search skips update releases whose version is not newer than this
POST /api/games/refresh-metadata JWT + igdbRateLimiter — { success, message, updatedCount, errorCount }
DELETE /api/games/:id JWT + sensitiveEndpointLimiter, sanitizeGameId, validateRequest Query: deleteFiles? (“true” to also remove the game’s files on disk) { success: true, fileDeletion }; 404 if not found. fileDeletion is null when deleteFiles wasn’t set, otherwise { deleted: true, path } or { deleted: false, reason: "outside-library-root"\|"delete-failed", path }. Files are only deleted when libraryPath resolves inside the configured library root or inside a root folder with allowDelete: true (see Root Folders below)
GET /api/games/:id/downloads JWT + sanitizeGameId, validateRequest (verifies game ownership) — GameDownload[]; 404 game not found, 403 not owner
GET /api/games/discover JWT + igdbRateLimiter Query: limit? Game[] (IGDB-formatted recommendations)
POST /api/games/match-and-add JWT { title: string } — manual typeof check, no validator 201 Game; 404 if no IGDB match; 409 if already in collection
POST /api/games/:gameId/blacklist JWT (explicit) { releaseTitle: string (required, ≤500) } via insertReleaseBlacklistSchema.safeParse 201 blacklist entry; 400 invalid; 403/404 via ownership check
GET /api/games/:gameId/blacklist JWT (explicit) — Blacklist entries array; 403/404 via ownership check
GET /api/games/:gameId/files JWT (explicit) + scanRateLimiter (10 req / min per user) — { files: { name, path, category, size }[], truncated: boolean } — recursive scan of the game’s library folder, bounded by SCAN_MAX_FILES (5,000) and a 5s wall-clock budget; truncated: true when either cap stopped the walk. Empty { files: [], truncated: false } for missing/escaped paths; 403/404 via ownership check; 500 on unexpected I/O errors
PATCH /api/games/:gameId/files/category JWT (explicit) Body: { path: string, category: "main" \| "dlc" \| "update" \| "extra" } { path, category } — stores a user-chosen category for a file inside the game’s folder (realpath must stay within the game folder and library root, else 404); sets game_files.category_overridden so later scans of /files report it instead of the folder/filename guess. 400 on invalid category; 403/404 via ownership check
DELETE /api/games/:gameId/blacklist/:id JWT (explicit) — 204; 404 if entry not found
DELETE /api/games/:id/downloads/:downloadId JWT + sanitizeGameId, sanitizeDownloadId, validateRequest — { success: true }; 404 if game/download not found

Collections

User-defined groups of games. A manual collection holds an explicit list of games; a smart collection stores rules and its games are computed from the user’s library on every read. Hidden games and games excluded by the user’s content-filter settings are never included. All routes require JWT and only see the requester’s own collections (404 otherwise).

Method Path Request Body Response
GET /api/collections — Collection[], each with gameCount and coverUrls (up to 4)
POST /api/collections { name (1–100), description? (≤500), type: "manual"\|"smart", rules? }. rules (smart only, at least one non-empty criterion — empty lists alone are rejected —, unknown keys rejected, releaseYearTo must not be earlier than releaseYearFrom): { statuses?, platforms?, genres?, minRating? (0–10), minUserRating? (0–10), releaseYearFrom?, releaseYearTo?, titleContains? } 201 Collection; 400 { error, details } (e.g. smart without rules). Rules are dropped for manual type
GET /api/collections/:collectionId — Collection & { games: Game[] }; 404 if not found
PATCH /api/collections/:collectionId Any of name, description, type, rules Updated Collection; 400 if the result would be a smart collection without rules; switching to manual clears rules
DELETE /api/collections/:collectionId — 204; 404 if not found. Games stay in the library
POST /api/collections/:collectionId/games { gameId } 201 { collectionId, gameId }; 400 on a smart collection; 404 if the game is not the user’s; 409 if already added
DELETE /api/collections/:collectionId/games/:gameId — 204; 404 if the game is not in the collection

Downloads

Method Path Auth Required Request Body Response
GET /api/downloads JWT (global gate) — { downloads: (Download & { downloaderId, downloaderName, trackedByQuestarr, downloaderCategory })[], errors: {...}[] }
GET /api/downloads/summary JWT (explicit) — Per-game download summary object
GET /api/downloads/scan JWT (explicit) — { groups: { baseTitle, downloads[], libraryMatch: {game, confidence}\|null }[] } — untracked downloads grouped/matched to library
POST /api/downloads/claim JWT (explicit) { downloaderId, downloadHash, downloadTitle, currentStatus, category, gameId? or newGame? } via claimDownloadRequestSchema.safeParse { success: true, gameId }; 409 if already linked; 400/404 on invalid input
POST /api/downloads JWT + sensitiveEndpointLimiter, sanitizeDownloaderDownloadData, validateRequest { url (http/https/magnet), title (1–500), category?, downloadPath? (no ".."), priority? (0–10), gameId? (uuid), downloadType?, password? (≤200 chars) } Add-with-fallback result across enabled downloaders; 400 if none configured; 500 on total failure
POST /api/downloads/bundle JWT + sensitiveEndpointLimiter { downloads: { link, title, downloadType? }[] } — manual array check only Streams a .zip of fetched torrent/nzb files

Downloaders

Method Path Auth Required Request Body Response
GET /api/downloaders JWT — Downloader[] (passwords masked as ********)
GET /api/downloaders/enabled JWT — Downloader[] (masked)
GET /api/downloaders/storage JWT — { downloaderId, downloaderName, freeSpace, error? }[], cached 30s server-side
GET /api/downloaders/:id JWT — Downloader (masked); 404 if not found
POST /api/downloaders JWT + sensitiveEndpointLimiter, sanitizeDownloaderData, validateRequest { name (1–200), type (transmission\|rtorrent\|qbittorrent\|sabnzbd\|nzbget), url (full URL or bare hostname/IP), username?, password?, enabled?, downloadPath? (no ".."), label?, urlPath? } via insertDownloaderSchema; SSRF-checked via isSafeUrl 201 Downloader (masked); 400 invalid/unsafe URL
PATCH /api/downloaders/:id JWT + sensitiveEndpointLimiter, sanitizeDownloaderUpdateData, validateRequest Partial of above plus priority?, category?; password equal to the redacted placeholder = keep unchanged Updated Downloader (masked); 404 if not found
DELETE /api/downloaders/:id JWT + sensitiveEndpointLimiter — 204; 404 if not found
POST /api/downloaders/test JWT { type, url, port?, useSsl?, urlPath?, username?, password?, downloadPath?, category?, label?, allowInsecureLan?, ... } — manual checks, SSRF-checked Connection test result object
POST /api/downloaders/:id/test JWT — Connection test result for saved downloader; 404 if not found
POST /api/downloaders/:id/downloads JWT + sensitiveEndpointLimiter, sanitizeDownloaderDownloadData, validateRequest { url, title, category?, downloadPath?, priority?, downloadType?, password? (≤200 chars) } Add-download result; 404 downloader not found; 400 if downloader disabled
GET /api/downloaders/:id/downloads JWT — Downloads array from that client; 404 if downloader not found
GET /api/downloaders/:id/downloads/:downloadId JWT — Single download status object; 404 if not found
GET /api/downloaders/:id/downloads/:downloadId/details JWT — Detailed info (files, trackers); 404 if not found
POST /api/downloaders/:id/downloads/:downloadId/pause JWT — Pause result object
POST /api/downloaders/:id/downloads/:downloadId/resume JWT — Resume result object
DELETE /api/downloaders/:id/downloads/:downloadId JWT Query: deleteFiles? Removal result object

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 a libraryPath 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).

The UI is Library → Library Import. Adding/enabling a folder or changing an enabled folder’s path starts an asynchronous scan. Enabling post-processing also scans enabled roots. Settings changes during an active scan queue a rescan. ScanProgress includes startedAt, optional finishedAt, status, candidate counts and match/review/error counts; updates also arrive as library-scan-progress. See LIBRARY_IMPORT.md for the operator workflow.

POST /api/library/scan/unmatched/search requires authentication, CSRF for cookie sessions, and the IGDB rate limiter. Body: { rootFolderId, folderName, query } (trimmed strings, limits 200/1000/200 respectively). Returns an array of { igdbId, name, releaseYear } candidates; searching does not import a game. Returns 400 for invalid input, 404 when the folder is no longer awaiting review, or 500 if IGDB search fails. Confirm a candidate using the match endpoint below.

Method Path Auth Required Request Body Response
GET /api/root-folders JWT — RootFolder[]
POST /api/root-folders JWT + sensitiveEndpointLimiter, sanitizeRootFolderData, validateRequest { path (1–1000, no null bytes), name? (≤200), enabled?, allowDelete? } via insertRootFolderSchema; path probed on disk. allowDelete defaults to false 201 RootFolder; 400 if path inaccessible; 409 if path already registered
PATCH /api/root-folders/:id JWT + sensitiveEndpointLimiter, sanitizeRootFolderId, sanitizeRootFolderUpdateData, validateRequest Partial: path?, name?, enabled?, allowDelete? Updated RootFolder; 400 if :id isn’t a UUID; 404 if not found; 409 if new path already registered
DELETE /api/root-folders/:id JWT + sensitiveEndpointLimiter, sanitizeRootFolderId, validateRequest — 204; 400 if :id isn’t a UUID; 404 if not found
POST /api/root-folders/:id/health-check JWT + sensitiveEndpointLimiter, sanitizeRootFolderId, validateRequest — Force-refreshed accessibility + disk stats for the folder; 400 if :id isn’t a UUID; 404 if not found
POST /api/library/scan JWT + sensitiveEndpointLimiter, sanitizeLibraryScanData, validateRequest { rootFolderId? } via sanitizeLibraryScanData — optional, 1–200 chars if given; omit to scan all enabled root folders 202 { accepted: true, rootFolderId }; scan runs asynchronously; 400 { error: "Validation failed", details } on invalid body
GET /api/library/scan/status JWT — ScanProgress[] — one entry per root folder scanned since server start
GET /api/library/scan/unmatched JWT — UnmatchedEntry[] — folders that didn’t confidently match an IGDB game
POST /api/library/scan/unmatched/match JWT + sensitiveEndpointLimiter, sanitizeUnmatchedMatchData, validateRequest { rootFolderId (1–200 chars), folderName (1–1000 chars), igdbId (positive integer) } via sanitizeUnmatchedMatchData { gameId, filesAdded }; 400 { error: "Validation failed", details } on invalid body; 500 with message on resolution failure

Import Settings

Mounted under /api/imports (JWT). See LIBRARY_IMPORT.md for naming tokens and rendering rules.

Method Path Auth Required Request Body Response
POST /api/imports/naming/preview JWT { pattern: string (1–200) } (strict, no other fields) { samples: { title, leafName, fullPath }[] } for up to 5 library games plus one sample release; nothing is renamed. 400 { error, errors: string[] } if the pattern is invalid; 400 { error } on bad body
PATCH /api/imports/config JWT Partial import config. renamePattern? (1–200) must pass naming validation: known tokens only, no / or \, no .., no control characters, balanced braces Updated import config; 400 { error } listing the validation messages on an invalid renamePattern or body

Example preview request:

curl -X POST http://localhost:5000/api/imports/naming/preview \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"pattern":"{Title} ({Year})"}'

Indexers

Method Path Auth Required Request Body Response
GET /api/indexers JWT — Indexer[] (apiKey masked)
GET /api/indexers/enabled JWT — Indexer[] (masked)
GET /api/indexers/search JWT (registered after the global auth gate, so optionalAuthenticateToken never actually sees an unauthenticated request) + sanitizeIndexerSearchQuery, validateRequest Query: query (1–200, required), category?/cat? (≤500), limit? (1–100), offset? (≥0), gameId? { items, total, offset, blacklistedCount?, errors? }
GET /api/indexers/:id JWT — Indexer (masked); 404 if not found
POST /api/indexers JWT + sensitiveEndpointLimiter, sanitizeIndexerData, validateRequest { name (1–200), url (http/https, SSRF-checked), apiKey? (≤500), protocol? (torznab\|newznab), enabled? } via insertIndexerSchema 201 Indexer (masked); 400 invalid/unsafe URL
PATCH /api/indexers/:id JWT + sensitiveEndpointLimiter, sanitizeIndexerUpdateData, validateRequest Partial: name?, url?, apiKey?, protocol?, enabled?, priority? (≥1), categories?, rssEnabled?, autoSearchEnabled?; apiKey placeholder = keep unchanged Updated Indexer (masked); 404 if not found
DELETE /api/indexers/:id JWT + sensitiveEndpointLimiter — 204; 404 if not found
POST /api/indexers/test JWT { name?, url (required, SSRF-checked), apiKey (required), enabled?, priority?, categories?, rssEnabled?, autoSearchEnabled?, allowInsecureLan? } — manual checks only Torznab connection test result
POST /api/indexers/:id/test JWT — Connection test result for saved indexer; 404 if not found
GET /api/indexers/:id/categories JWT — Category list from indexer; 404 if not found
GET /api/indexers/:id/search JWT Query: query (required), category?/cat?, limit? (default 50), offset? (default 0) Search results from that single indexer; 400 if query missing, 404 if not found
GET /api/indexers/prowlarr/settings JWT — Last successful sync values { url, apiKey: "********", allowInsecureLan, priority, categories }, or null
POST /api/indexers/prowlarr/sync JWT + sensitiveEndpointLimiter { url: string, apiKey: string, allowInsecureLan?: boolean, priority?: 1–100, categories?: string[] } — optional fields apply to every synced indexer; apiKey: "********" reuses the saved key for the saved URL only; values are remembered after a successful sync; SSRF-checked { success, message, results }
GET /api/search JWT (same global-gate note as above; alias of /api/indexers/search) + sanitizeIndexerSearchQuery, validateRequest Same as /api/indexers/search Same as /api/indexers/search

IGDB

Method Path Auth Required Request Body Response
GET /api/igdb/search JWT + igdbRateLimiter, sanitizeSearchQuery, validateRequest Query: q (1–200, required), limit? (1–100) Formatted IGDB game array
GET /api/igdb/popular JWT + igdbRateLimiter Query: limit? Formatted game array; Cache-Control: public, max-age=3600, stale-while-revalidate=600
GET /api/igdb/recent JWT + igdbRateLimiter Query: limit? Formatted game array; same cache header
GET /api/igdb/upcoming JWT + igdbRateLimiter Query: limit? Formatted game array; same cache header
GET /api/igdb/genre/:genre JWT + igdbRateLimiter Path :genre (≤100 chars); query limit?, offset? Formatted game array; same cache header
GET /api/igdb/platform/:platform JWT + igdbRateLimiter Path :platform (≤100 chars); query limit?, offset? Formatted game array; same cache header
GET /api/igdb/genres JWT + igdbRateLimiter — Genre list; Cache-Control: public, max-age=86400, stale-while-revalidate=3600
GET /api/igdb/platforms JWT + igdbRateLimiter — Platform list; same 24h cache header
GET /api/igdb/game/:id JWT + igdbRateLimiter, sanitizeIgdbId, validateRequest Path :id (positive integer) Formatted IGDB game; 404 if not found

Settings

Method Path Auth Required Request Body Response
GET /api/settings/ssl authenticateToken (pre-gate) — SSL config plus optional certInfo (subject, issuer, validity, selfSigned)
PATCH /api/settings/ssl authenticateToken + sensitiveEndpointLimiter (pre-gate) { enabled: boolean, port: number, certPath?, keyPath?, redirectHttp? } — manual checks; cert/key paths path-traversal checked against FILE_BROWSER_ROOT { success, message }; 400/403 on invalid config or disallowed path
POST /api/settings/ssl/generate authenticateToken + sensitiveEndpointLimiter (pre-gate) — { success, message, certPath, keyPath } (self-signed cert)
POST /api/settings/ssl/upload authenticateToken + sensitiveEndpointLimiter (pre-gate) Multipart form: cert file + key file (5 MB limit, memory storage); validated as PEM { success, message, certPath, keyPath }; 400 if missing/invalid PEM
GET /api/system/filesystem authenticateToken + sensitiveEndpointLimiter (pre-gate) Query: path? (relative; absolute paths/NUL bytes rejected; confined to FILE_BROWSER_ROOT) { path, parent, files: {name, path, isDirectory, size}[] }; 403 on traversal, 404 if missing
GET /api/config sensitiveEndpointLimiter only, no JWT (pre-gate, public) — { igdb: { configured, source }, xrel: { apiBase } }
GET /api/settings/igdb JWT + sensitiveEndpointLimiter — { configured, source, clientId }
POST /api/settings/igdb JWT + sensitiveEndpointLimiter { clientId: string (required), clientSecret?: string } — manual checks; redacted-placeholder secret = keep existing { success: true }; 400 if client secret required but missing
GET /api/settings/discord JWT + sensitiveEndpointLimiter — { configured: boolean, webhookUrl?: "********" }
POST /api/settings/discord JWT + sensitiveEndpointLimiter { webhookUrl?: string } — must be a discord.com/discordapp.com webhook URL; redacted placeholder = keep unchanged { success: true }; 400 on invalid webhook URL
GET /api/settings JWT — UserSettings (created with defaults if none exist)
PATCH /api/settings JWT Validated via updateUserSettingsSchema (Zod) Updated UserSettings; 400 { error, details } on Zod failure
PATCH /api/settings/xrel JWT { apiBase?: string (http/https, SSRF-checked, must match ALLOWED_XREL_DOMAINS), xrelSceneReleases?: boolean, xrelP2pReleases?: boolean } — manual checks { success, xrel: { apiBase }, settings? }; 400 on invalid/unsafe/unauthorized domain
GET /api/settings/nexusmods JWT + sensitiveEndpointLimiter — { configured: boolean, source?: "env"\|"database" }
POST /api/settings/nexusmods JWT + sensitiveEndpointLimiter { apiKey: string (required) } { success: true }; 400 if missing

Apprise settings

These authenticated endpoints configure the shared Apprise service. Cookie sessions require the usual CSRF header on POST requests.

Method Path Request / response
GET /api/settings/apprise { configured, mode, apiUrl, key, urls, username, password }; the password is masked as ******** when saved, including in CLI mode.
POST /api/settings/apprise { mode: "api" or "cli", apiUrl?, key?, urls?, username?, password? }; returns { success: true }. API mode requires an API URL and a config key or notification URLs; CLI mode requires notification URLs.
POST /api/settings/apprise/test Sends a test using saved settings; { success: true }, or 502 with an error if delivery fails.

The optional API login is for Apprise API servers with APPRISE_AUTH_REQUIRED=yes. Usernames cannot contain a colon. The password is encrypted at rest; sending the masked placeholder keeps it, an empty string clears it, and omitting the field leaves it unchanged. HTTPS login requests reject plaintext or cross-origin redirects. An HTTP login sends credentials unencrypted and the UI warns about it; use HTTPS unless the connection is on a trusted network.

RSS

Enabled feeds refresh 10 seconds after startup and every hour. Manual full refreshes share a queued/running scheduled refresh; single-feed refreshes after creation run in the same serialized queue. Disabled feeds are skipped by full refreshes. The browser polls items every five minutes.

Method Path Auth Required Request Body Response
GET /api/rss/feeds JWT — RssFeed[]
POST /api/rss/feeds JWT Validated via insertRssFeedSchema (Zod); URL SSRF-checked 201 RssFeed; 400 on Zod/unsafe-URL failure (triggers async initial refresh)
PUT /api/rss/feeds/:id JWT Partial insertRssFeedSchema Updated RssFeed; 404 if not found; 400 if unsafe URL
DELETE /api/rss/feeds/:id JWT — 204; 404 if not found
GET /api/rss/items JWT Query: limit? (default 100) RssFeedItem[]
POST /api/rss/refresh JWT — { success: true } (triggers refresh of all feeds)

Notifications

PATCH /api/settings accepts notificationPreferences as a JSON-encoded string. Within gameUpdates, includeShelved and includeCompleted independently control update and pack/add-on notifications for those statuses, for both In-App and Apprise. Missing values mean true for compatibility with older preferences. Availability searches and badges still update when notifications are muted.

Method Path Auth Required Request Body Response
GET /api/notifications JWT (explicit) Query: limit? (default 50) Notification[]
GET /api/notifications/unread-count JWT (explicit) — { count: number }
POST /api/notifications JWT (explicit) Validated via insertNotificationSchema (Zod) 201 Notification (also emitted over Socket.io); 400 on Zod failure
PUT /api/notifications/:id/read JWT (explicit) — Updated Notification; 404 if not found
PUT /api/notifications/read-all JWT (explicit) — { success: true }
DELETE /api/notifications JWT (explicit) — 204 (clears all read 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.

Method Path Auth Required Request Body Response
GET /api/telemetry/pending/:reportId JWT (explicit) — { lineCount, appVersion, platform, timestamp }; 404 if expired/unknown/not owned by the caller
POST /api/telemetry/pending/:reportId/send JWT (explicit) — { code, issueNumber } (same shape as the manual “Send Logs” flow); 422 if unavailable (unknown/expired/not owned) or delivery fails

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

Method Path Auth Required Request Body Response
GET /api/xrel/latest JWT Query: page? (default 1) { ...xrelResult, list: [...releases annotated with libraryStatus, gameId, isWanted, matchCandidate?] }
GET /api/xrel/search JWT Query: q (required), scene? (default true unless “false”/”0”), p2p?, limit? (1–100, default 25) { results: [...] }; 400 if q missing

PATCH /api/settings/xrel (xREL API base URL and scene/P2P release preferences) is documented under Settings.

NexusMods

Method Path Auth Required Request Body Response
GET /api/nexusmods/game-domain JWT Query: title (required) { configured: boolean, domain: string\|null }; 400 if title missing
GET /api/nexusmods/trending-mods JWT Query: domain (required), limit? (1–20, default 10) Trending mods array; 400 if domain missing

NexusMods API-key configuration (GET/POST /api/settings/nexusmods) is documented under Settings.

Stats

Method Path Auth Required Request Body Response
POST /api/stats/discord-share JWT { image: string (data URI, png/jpeg/gif/webp base64), message?: string } — manual checks; webhook URL must match Discord’s domain pattern { success: true }; 400 on missing/invalid config or image; 502 if Discord request fails

Health / Ready / Config

Method Path Auth Required Request Body Response
GET /api/health None (public, pre-gate) — { status: "ok" } (liveness probe only, no dependency checks)
GET /api/ready JWT (registered after the global auth gate, so a token is required despite being a readiness probe) — { status: "ok" } (200) if DB + IGDB checks succeed, else { status: "error" } (503)
GET /api/config sensitiveEndpointLimiter only, no JWT (public, pre-gate) — { igdb: { configured, source }, xrel: { apiBase } }

Dashboard Status

Method Path Auth Required Request Body Response
GET /api/status JWT — { totalGames, pendingWishlist, activeDownloads, recentImports: { count, items: [{ gameId, title, completedAt }] } } — lightweight stats for external dashboard widgets (Homepage, Homarr, Organizr, etc.), excludes hidden games; response sent with Cache-Control: no-store. pendingWishlist counts games with status wanted; activeDownloads counts downloads with status downloading/paused; recentImports is scoped to downloads completed in the last 7 days — count is the total in that window, items are the same window’s 5 most recent (never older imports, even when fewer than 5 exist).

HowLongToBeat

Method Path Auth Required Request Body Response
GET /api/hltb/lookup JWT (explicit) Query: title (required) { data: HltbResult \| null }; 400 if title missing

Blacklist

Method Path Auth Required Request Body Response
GET /api/blacklist JWT (explicit) — All blacklist entries across the user’s games (ReleaseBlacklist[])

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.

Method Path Auth Required Request Body Response
PATCH /api/user/steam-id JWT { steamId: string } — must be a 17-digit SteamID64 starting with 7656 (steamService.validateSteamId) { success: true, steamId }; 400 if missing/invalid format
POST /api/steam/wishlist/sync JWT — { success, addedCount, games } (via syncUserSteamWishlist); 400 if Steam ID not linked or sync fails

PCGamingWiki

server/pcgamingwiki-router.ts — 1 route.

Method Path Auth Required Request Body Response
GET /api/external/pcgamingwiki JWT Query: steamAppId (required, positive integer) { url: string \| null }; 400 if steamAppId missing/invalid; result cached 24h (5min on lookup failure)

Integration API (external clients)

server/routes/integration.ts — 4 routes, mounted at /api/integration, plus the SeerrNG provider routes under /api/integration/seerrng/v1 (server/routes/seerrng.ts, documented in SEERRNG.md).

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

Each key carries one scope and an optional expiry (see API Keys (management)). A route declares the scopes that may call it with requireScope(...); all satisfies every route, and a JWT session is never restricted by scope. A key outside a route’s scope gets 403 { error, code: "API_KEY_SCOPE_DENIED" }; an expired key gets 401 { error: "API key expired", code: "API_KEY_EXPIRED" }. Requests are limited to 300/min per key (integrationRateLimiter).

Route library:write read requests all
GET /ping ✓ ✓ ✓ ✓
GET /library ✓ ✓ — ✓
POST /library/sync, POST /games/request ✓ — — ✓
/seerrng/v1/* (see SeerrNG provider) — — ✓ ✓

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.

Method Path Auth Required Request Body Response
GET /api/integration/ping JWT or API key — { service: "questarr", version, apiVersion, authenticatedAs: { id, username }, usingApiKey, scope, expiresAt } (scope/expiresAt are null for a JWT session; expiresAt is null for a key that never expires)
GET /api/integration/library JWT or API key Query: status (optional, comma-separated), includeHidden (optional, "true"/"false") { games: IntegrationGame[], count }; 400 on invalid query
POST /api/integration/library/sync JWT or API key { games: Array<{ title, externalId?, installed?, steamAppId? }> (1–5000), markInstalledAsOwned?: boolean } — Zod-validated { received, matched, unmatched, promotedToOwned }; 400 on invalid payload
POST /api/integration/games/request JWT or API key { title: string, status?: "wanted" \| "owned" } — Zod-validated 201 { game }; 404 if IGDB has no match or the match is content-filtered; 409 if already in library

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. last_used_at is written at most once per minute per key.

Method Path Auth Required Request Body Response
GET /api/api-keys JWT — Array<{ id, userId, name, prefix, createdAt, lastUsedAt, scope, expiresAt }> — never includes the hash
POST /api/api-keys JWT + sensitiveEndpointLimiter { name: string (1–100 chars), scope?: "all" \| "library:write" \| "read" \| "requests" (default "all"), expiresInDays?: 1–365 \| null (default: never) } 201 { id, userId, name, prefix, createdAt, lastUsedAt, scope, expiresAt, key } — key is shown only here; 409 once 25 keys exist
DELETE /api/api-keys/:id JWT + sensitiveEndpointLimiter — 204; 404 if the key does not exist or belongs to another user

Error Format

Across handlers, error responses follow one of these consistent shapes:

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:

Event Emitted from Payload Consumed by
notification cron.ts (game updates, download completion, auto-search results, xREL matches) and routes.ts Notification object client/src/components/NotificationCenter.tsx
downloadUpdate cron.ts::checkDownloadStatus gameId: string client/src/components/GameDetailsModal.tsx

Every authenticated socket joins a per-user room, user:<id>, during the handshake. Delivery depends on whether the event has an owner:

Clients don’t need to join rooms themselves. Connect with the auth cookie (or a bearer token in auth.token) and the server assigns the room:

import { io } from "socket.io-client";

const socket = io({ withCredentials: true });
socket.on("notification", (n) => console.log("for me:", n));
socket.on("downloadUpdate", (gameId) => console.log("download changed:", gameId));