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.
- 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.
| 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 }; new password uses the shared 8+ character, letter-and-number policy |
{ 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 and credential checks |
{ token, user: { id, username } }; 401 on invalid credentials; failed attempts log the trimmed username and request IP |
| GET | /api/auth/me |
authenticateToken (JWT) |
— | { id, username, steamId64 } |
| PATCH | /api/auth/password |
authenticateToken + sensitiveEndpointLimiter |
{ currentPassword: string, newPassword: string, confirmPassword: string } — new password uses the shared 8+ character, letter-and-number policy |
{ success: true, message }; 401 if current password wrong; 400 { error, details } on Zod failure |
/api/auth/setup and /api/auth/login use manual request-shape checks rather
than an express-validator chain. Account setup and password changes share
passwordPolicySchema: new passwords are trimmed, at least eight characters,
and contain at least one letter and one number. Failed login attempts retain the
generic 401 response; the server writes a warning with the trimmed username and
request IP, never the submitted password. Local operator recovery is documented
in Password recovery.
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.
| 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 | status: wanted, owned, playing, shelved, completed, or downloading; query: includeHidden? |
Game[]; 400 for an unsupported status |
| 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; 403 if the game belongs to another user; 404 if not found |
| PATCH | /api/games/:id/hidden |
JWT + sensitiveEndpointLimiter, sanitizeGameId, validateRequest |
{ hidden: boolean } via updateGameHiddenSchema |
Updated Game; 403 if the game belongs to another user; 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 }; 403 if the game belongs to another user; 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 + sanitizeMatchAndAddTitle, validateRequest |
{ title: string } — trimmed, 1–500 characters |
201 Game; 400 on invalid title; 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 using streamed directory entries. Each scan is bounded by 5,000 files, an 8 MiB encoded response, directory depth 128, and a 5s wall-clock budget. truncated: true when any budget stops 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 |
| 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 |
| 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|realdebrid), 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/realdebrid/auth/start |
JWT + sensitiveEndpointLimiter |
— | Real-Debrid device code and one-time user code |
| POST | /api/downloaders/:id/realdebrid/auth/poll |
JWT + sensitiveEndpointLimiter |
{ handle } from the start response |
Pending/connected state; stores OAuth credentials encrypted at rest |
| 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 |
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 |
| 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?, allowInsecureLan? (boolean, default false) } 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?, allowInsecureLan?; 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 + sanitizeIndexerSearchQuery, validateRequest |
Query: query (1–200 characters, required), category?/cat? (≤500), limit? (1–100, default 50), offset? (safe non-negative integer, default 0), fileTypes? (up to ten extensions) |
Search results from that single indexer; 400 for invalid query values, 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 } |
| POST | /api/indexers/prowlarr/diagnose |
JWT + sensitiveEndpointLimiter |
{ url: string, apiKey: string, allowInsecureLan?: boolean } |
Prowlarr management API result plus per-indexer feed search status; does not save settings |
| 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 |
allowInsecureLan is a per-indexer opt-in persisted with the indexer record and
defaults to false. It permits plaintext HTTP only for trusted local-network
services. Prowlarr management API requests require HTTPS unless the operator
explicitly opts in to insecure LAN transport. When the sync setting is enabled,
imported HTTP indexers retain that opt-in, and requests do not follow redirects
that could forward an API key to another host.
| 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 |
| 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 |
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.
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) |
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) |
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).
| 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.
| Method | Path | Auth Required | Request Body | Response |
|---|---|---|---|---|
| GET | /api/nexusmods/game-domain |
JWT + sanitizeNexusModsGameDomainQuery, validateRequest |
Query: title (1–500 characters, required) |
{ configured: boolean, domain: string|null }; 400 for an invalid title |
| GET | /api/nexusmods/trending-mods |
JWT + sanitizeNexusModsTrendingModsQuery, validateRequest |
Query: domain (1–200 characters, required), limit? (1–20, default 10) |
Trending mods array; 400 for invalid query values |
NexusMods API-key configuration (GET/POST /api/settings/nexusmods) is
documented under Settings.
| 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 |
| 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 } } |
| 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). |
| Method | Path | Auth Required | Request Body | Response |
|---|---|---|---|---|
| GET | /api/hltb/lookup |
JWT (explicit) | Query: title (required) |
{ data: HltbResult | null }; 400 if title missing |
| 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.
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 |
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) |
server/routes/integration.ts — external-client 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.
| Method | Path | Auth Required | Request Body | Response |
|---|---|---|---|---|
| GET | /api/integration/ping |
JWT or API key | — | { service: "questarr", version, apiVersion, authenticatedAs: { id, username }, usingApiKey } |
| 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.
These versioned endpoints serve SeerrNG's PC-game catalog and request lifecycle.
They use the same API-key/JWT authentication and caller-owned game scope as the
integration routes above. Responses are marked Cache-Control: no-store.
After authentication, this route family is limited to 300 requests per minute
per key or JWT user, in addition to the global per-IP limit.
The machine-readable OpenAPI contract lives at
docs/contracts/seerrng-v2.openapi.yaml
and is parsed by the provider contract test suite.
| Method | Path | Request | Response and behavior |
|---|---|---|---|
| GET | /api/integration/seerrng/v1/ping |
— | { service: "QuestarrNG", version, apiVersion: 1, requestContractVersion: 2, capabilities }; capability flags report catalog, PC acquisition, request actions, and asset streaming support |
| GET | /api/integration/seerrng/v1/catalog/search?q=...&limit=... |
Search query, limit 1–50 | IGDB catalog titles; 400 for invalid input |
| GET | /api/integration/seerrng/v1/catalog/popular?limit=... |
Limit 1–50 | Popular IGDB titles |
| GET | /api/integration/seerrng/v1/catalog/platforms |
— | IGDB platform directory |
| GET | /api/integration/seerrng/v1/catalog/games/:igdbId/editions |
— | Editions of one game with the content each links, plus standalone expansions and add-ons. Content IGDB does not describe is storyContent: null and an edition with no linked content is contentKnown: false; nothing is guessed. |
| GET | /api/integration/seerrng/v1/catalog/discover?themes=...&gameModes=...&perspectives=...&genres=...&languages=...&excludeThemes=...&excludeGameModes=...&excludeGenres=...&minYear=...&maxHoursToBeat=...&q=...&cursor=... |
Comma-separated facet names, limit 1–48 | IGDB games matching the facets with their themes, modes, perspectives, and language support; adult-content filtering follows the user's setting |
| GET | /api/integration/seerrng/v1/catalog/games/{igdbId} |
Positive IGDB ID; optional positive platformId |
One catalog title; 404 if missing. With platformId, includes platformReleaseDate for the exact selected platform, or null when IGDB has no day-precision date. |
| POST | /api/integration/seerrng/v1/requests |
{ externalRequestId, title, igdbId?, variant } |
Idempotently links an IGDB game to a SeerrNG request, records its target, and starts an immediate search; 201 new, 200 duplicate, 404 no valid PC game, 409 linked elsewhere |
| GET | /api/integration/seerrng/v1/requests/{externalRequestId} |
— | Current provider state, deliverable, stable IGDB identity, and per-request actions.retry / actions.cancel; filesystem paths are not returned |
| POST | /api/integration/seerrng/v1/requests/{externalRequestId}/retry |
{ confirmNoExistingDownload?: boolean } |
Restarts the same request's search; 409 if cancelled or already available. After a restart interrupts an untracked download handoff, a 409 asks the caller to check the download queue/history; retry again with confirmNoExistingDownload: true only after confirming no matching download exists |
| POST | /api/integration/seerrng/v1/requests/{externalRequestId}/cancel |
{ confirmNoExistingDownload?: boolean } |
Stops active downloads linked to this external request and preserves downloaded files; 409 if an active download cannot be safely attributed to it or the request is available. After an interrupted handoff, a 409 asks the caller to check the queue/history before retrying cancellation with confirmNoExistingDownload: true |
| GET | /api/integration/seerrng/v1/requests/{externalRequestId}/assets |
— | Local imported assets as opaque IDs, names, and sizes; no paths |
| GET | /api/integration/seerrng/v1/requests/{externalRequestId}/assets/bundle |
— | Streams a title-named .tar.gz when at least two deliverable files remain inside the library root |
| GET | /api/integration/seerrng/v1/requests/{externalRequestId}/assets/{assetId} |
Optional Range header |
Streams a request-scoped file only when its canonical path remains under the user's configured library root |
Paged catalog endpoints are also available:
GET /api/integration/seerrng/v1/catalog/search-page?q=...&limit=...&cursor=...&platformIds=...&genre=...&releaseYear=...returns{ results, nextCursor }. The cursor is optional on the first request, bound to the query and filters, andnullafter the last page.GET /api/integration/seerrng/v1/catalog/popular-page?limit=...&offset=...&platformIds=...&genre=...&releaseYear=...returns{ results, nextOffset }. The offset starts at zero and isnullafter the last page. Genre and year are applied before each page is returned.GET /api/integration/seerrng/v1/library/lookup?igdbIds=42,43returns{ games: [{ igdbId, status }] }for up to 100 requested IDs in the authenticated user's visible library. Hidden and content-filtered titles are excluded.GET /api/integration/seerrng/v1/catalog/games/{igdbId}includes bounded screenshots, video IDs, rating, publishers, and developers for a title detail view. AddplatformIdto also request an exact day-precision release date for that IGDB platform; a missing platform date is returned asnull, never replaced with the game's global first-release date. Paged catalog results remain compact.
Both endpoints accept a limit of 1–50 and an optional comma-separated list of up to 100 positive IGDB platform IDs. Platform matching happens before pages are returned. The paged search keeps one IGDB query strategy and tracks canonical game IDs in its cursor, avoiding repeated editions across pages. Older array-returning endpoints remain available.
externalRequestId is the SeerrNG idempotency key, limited to 255 characters.
The selected PC target contains an operating system and architecture. Search,
download, and import use Questarr's existing indexer and downloader pipeline.
An asset is downloadable only after it has been imported under the configured
library root; the API never returns its filesystem path. Cancellation is
correlated at the download-record level and refuses to stop unrelated or
legacy downloads associated with the same game.
Prowlarr diagnostics are separate from indexer sync. They first check the
Prowlarr management API, then issue a one-result feed search against each
enabled torrent or usenet indexer. This reports feed-level HTTP failures such
as 401 Unauthorized next to the indexer name; it does not alter Prowlarr or
Questarr configuration.
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. New keys expire after 90 days by default (up to one
year) and can use integration:seerrng or integration:all scope. Existing
keys were migrated with integration:all and no expiration to preserve their
current behavior. A user may hold at most 25 keys.
| Method | Path | Auth Required | Request Body | Response |
|---|---|---|---|---|
| GET | /api/api-keys |
JWT | — | Array<{ id, userId, name, prefix, scope, createdAt, lastUsedAt, expiresAt }> — never includes the hash |
| POST | /api/api-keys |
JWT + sensitiveEndpointLimiter |
{ name, scope?, expiresInDays? } (1–100 chars; scope: integration:all or integration:seerrng; expiry: 1–365 days, default 90) |
201 key object plus key — 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 |
For an /api JSON response with an error status and an object body containing
error or message, the server adds an errorCode field. Existing response
fields and details are preserved, and an explicit handler-provided code takes
precedence. Otherwise the code is derived from the HTTP status (for example,
api.bad_request, api.not_found, or api.server_error). Clients can use this
stable api.* value for programmatic handling; error and message retain
the English detail text.
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:{ "error": "Validation failed", "errorCode": "api.bad_request", "details": [/* express-validator error objects: { type, msg, path, location, value } */] } - 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"){ "error": "Invalid <thing> data", "errorCode": "api.bad_request", "details": [/* ZodError.errors array */] } - 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 from{ "error": "<message, or 'Internal Server Error' in production for 5xx>", "errorCode": "api.server_error", "details": "<only if err.details was set>" }err.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).
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:
notificationevents that carry auserIdgo only to that user's sockets (io.to("user:<id>")). Other accounts on the same instance don't receive them.- Ownerless notifications and app-wide events (
downloadUpdate,importTaskUpdate,gameUpdated,library-scan-progress,logLine) are broadcast to every authenticated socket (io.emit).
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));POST /api/integration/seerrng/v1/requests accepts an optional selection next to igdbId:
{ "kind": "game-content", "mode": "missing-selected", "addonIds": [1234, 5678] }
{ "kind": "game-content", "mode": "edition", "editionId": 9012 }Rules: a selection requires igdbId; every id must be an expansion, add-on, or edition IGDB links to that game, otherwise the request is rejected with 400. An existing library game is linked rather than re-added, so the person's ownership is untouched. An edition selection is met only by releases that name that edition, and an add-on selection only by releases that name the add-on or are a complete bundle. When nothing matches, nothing is substituted. The request status stays searching or downloading while selected content is outstanding, and the response carries content: { mode, pending, acquiredCount }.
GET /api/wanted-content (authenticated) lists editions and add-ons that SeerrNG requests selected and that are not acquired yet. The Library page shows them in a Requested editions and add-ons card.