REST Endpoints
Every REST route on the Mosaic remote interface, grouped by resource, with request/response schemas.
This is the complete REST surface mounted by backend/remote.Mount on the /api prefix. Every route except POST /api/login is gated by AuthGate. Every state-changing route except bearer-keyed callers is gated by OriginGuard.
DTO field names below are the JSON tags from backend/api; types are the Go-side shape. Where a DTO appears in multiple routes, its full schema is shown once at the top of its section.
CSRF carve-out reminder.
OriginGuardallows requests that have neither anOriginnor aRefererheader —SameSite=Stricton the session cookie means a real browser-mediated CSRF wouldn’t carry the cookie, and most non-browser clients omit both headers. Cookie-authed requests are not unconditionally Origin-checked. See Authentication for the full algorithm.
Authentication
POST /api/login
Public. Rate-limited per IP (5/min, burst 5). Issues a session cookie on success.
Request body
{ "username": "string", "password": "string" }
Responses
200{"ok": true}— setsmosaic_sessioncookie400body decode failure401{"error": "invalid credentials"}429{"error": "too many login attempts"}—Retry-After: 12is a constant (12 seconds) regardless of how many tokens are missing from the bucket; clients should treat it as “wait at least 12s and try once”.
curl -k -c cookies.txt -X POST https://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"hunter2"}'
POST /api/logout
Auth: session OR bearer. Origin guard applies.
Request body: none.
Response: 200 {"ok": true}. Deletes the server-side session and clears the cookie.
Torrents
TorrentDTO shape
{
id: string; // info-hash
name: string;
magnet: string;
save_path: string;
total_bytes: number;
bytes_done: number;
progress: number; // 0..1
download_rate: number; // bytes/sec
upload_rate: number; // bytes/sec
peers: number;
seeds: number;
paused: boolean;
completed: boolean;
added_at: number; // unix seconds
category_id: number | null;
tags: TagDTO[];
queue_position: number;
force_start: boolean;
queued: boolean; // capped by queue limits
verifying: boolean; // recheck in progress
files_missing: boolean; // on-disk pieces vanished between sessions
}
GET /api/torrents
List all torrents. Stable sort: newest-first by added_at, tie-break by id.
Response: 200 [TorrentDTO, ...]
curl -k https://localhost:8080/api/torrents \
-H "Authorization: Bearer $MOSAIC_API_KEY"
POST /api/torrents/magnet
Add a torrent by magnet URI. Persists the magnet so it survives restart.
Request body
{ "magnet": "magnet:?xt=urn:btih:...", "save_path": "/optional/path" }
save_path is optional — empty defaults to GetDefaultSavePath().
Responses
200{"id": "<info-hash>"}400invalid magnet, engine refused, or persistence failed
curl -k -X POST https://localhost:8080/api/torrents/magnet \
-H "Authorization: Bearer $MOSAIC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"magnet":"magnet:?xt=urn:btih:...&dn=...","save_path":""}'
POST /api/torrents/file
Add a torrent by uploading a .torrent file. multipart/form-data only; max 10 MiB body.
Form fields
file(file) — the.torrentblobsave_path(string, optional) — defaults to the global default
Responses
200{"id": "<info-hash>"}400parse / read / engine errors
curl -k -X POST https://localhost:8080/api/torrents/file \
-H "Authorization: Bearer $MOSAIC_API_KEY" \
-F "file=@./ubuntu.torrent" \
-F "save_path=/srv/downloads"
POST /api/torrents/{id}/pause
{id} is the info-hash. Response: 200 {"ok": true}.
POST /api/torrents/{id}/resume
Response: 200 {"ok": true}.
POST /api/torrents/{id}/recheck
Forces piece-by-piece reverification against the on-disk files.
Response: 200 {"ok": true}.
DELETE /api/torrents/{id}
Remove a torrent. Query param delete=1 also wipes the on-disk files; otherwise files are left in place.
Response: 200 {"ok": true}.
curl -k -X DELETE "https://localhost:8080/api/torrents/$INFOHASH?delete=1" \
-H "Authorization: Bearer $MOSAIC_API_KEY"
POST /api/torrents/category
Set or clear the category for a torrent.
Request body
{ "infohash": "string", "category_id": 7 }
category_id: null clears the category.
Response: 200 {"ok": true}.
POST /api/torrents/file_priorities
Set per-file piece priorities for a torrent.
Request body
{
"infohash": "string",
"priorities": { "0": "skip", "1": "normal", "2": "high", "3": "max" }
}
Keys are the file index (as string in JSON); values are one of "skip" | "normal" | "high" | "max". Any string outside that set is treated as "normal".
Response: 200 {"ok": true}.
POST /api/torrents/queue_position
{ "infohash": "string", "pos": 0 }
Lower pos means higher priority in the queue. Response: 200 {"ok": true}.
POST /api/torrents/force_start
{ "infohash": "string", "force": true }
force=true bypasses the queue limit for this torrent. Response: 200 {"ok": true}.
Stats
GET /api/stats
Response: 200 GlobalStats
{
total_torrents: number;
active_torrents: number; // count of torrents where !Paused && !Completed
seeding_torrents: number; // count of torrents where Completed === true (regardless of Paused)
total_download_rate: number; // bytes/sec
total_upload_rate: number;
total_peers: number;
}
Inspector
The inspector is a per-client focus channel: tell the backend which torrent the user is looking at and which tabs are visible, and the inspector:tick WebSocket frame will scope to just that detail.
POST /api/inspector/focus
{
"id": "<info-hash>",
"tabs": ["overview", "files", "peers", "trackers", "speed"]
}
Empty id clears the focus. Tabs control which optional sections of DetailDTO get populated:
"files"populatesfiles[]"peers"populatespeers_list[]"trackers"populatestrackers[]"overview"and"speed"are accepted by the request body but are inert —scopeForTabsdoesn’t set any additional flags for them. Pass them or omit them; the resulting payload is the same.
Response: 200 {"ok": true}.
POST /api/inspector/clear
No body. Response: 200 {"ok": true}.
DetailDTO shape
{
id: string;
name: string;
magnet: string;
save_path: string;
total_bytes: number;
bytes_done: number;
progress: number;
ratio: number;
total_down: number; // lifetime bytes downloaded
total_up: number; // lifetime bytes uploaded
peers: number;
seeds: number;
added_at: number;
files?: FileDTO[];
peers_list?: PeerDTO[];
trackers?: TrackerDTO[];
}
// FileDTO
{ index: number; path: string; size: number; bytes_done: number; progress: number; priority: "skip"|"normal"|"high"|"max" }
// PeerDTO
{ ip: string; port: number; client: string; flags: string; progress: number; download_rate: number; upload_rate: number; country: string }
// TrackerDTO
{ url: string; status: string; seeds: number; peers: number; downloaded: number; last_announce: number; next_announce: number }
DetailDTO is delivered through the inspector:tick WebSocket frame — there’s no REST GET for it. See WebSocket.
Categories
CategoryDTO
{ id: number; name: string; default_save_path: string; color: string }
GET /api/categories
Response: 200 [CategoryDTO, ...].
POST /api/categories
{ "name": "Movies", "default_save_path": "/media/movies", "color": "#7c3aed" }
Response: 200 {"id": 1}.
PUT /api/categories
{ "id": 1, "name": "Movies", "default_save_path": "/media/movies", "color": "#7c3aed" }
Response: 200 {"ok": true}.
DELETE /api/categories/{id}
Response: 200 {"ok": true}.
Tags
TagDTO
{ id: number; name: string; color: string }
GET /api/tags
Response: 200 [TagDTO, ...].
POST /api/tags
{ "name": "linux-iso", "color": "#67e8f9" }
Response: 200 {"id": 4}.
DELETE /api/tags/{id}
Response: 200 {"ok": true}.
POST /api/tags/assign
{ "infohash": "string", "tag_id": 4 }
Response: 200 {"ok": true}.
POST /api/tags/unassign
Same body shape as assign. Response: 200 {"ok": true}.
Settings: save path
GET /api/settings/save_path
Response: 200 {"path": "/Users/me/Downloads"}.
PUT /api/settings/save_path
{ "path": "/srv/downloads" }
Response: 200 {"ok": true}.
Settings: bandwidth limits
LimitsDTO
{
down_kbps: number;
up_kbps: number;
alt_down_kbps: number;
alt_up_kbps: number;
alt_active: boolean;
}
0 for either kbps field means no limit.
GET /api/settings/limits
Response: 200 LimitsDTO.
PUT /api/settings/limits
Body: LimitsDTO. Response: 200 {"ok": true}.
POST /api/settings/alt_speed/toggle
Flip the alt-speed override. Response: 200 {"alt_active": true} (the new state).
Settings: queue
QueueLimitsDTO
{ max_active_downloads: number; max_active_seeds: number }
0 means unlimited (the queue scheduler is bypassed).
GET /api/settings/queue_limits
Response: 200 QueueLimitsDTO.
PUT /api/settings/queue_limits
Body: QueueLimitsDTO. Response: 200 {"ok": true}.
Settings: peer
PeerLimitsDTO
{
listen_port: number; // 0 lets anacrolix pick at startup
max_peers_per_torrent: number; // 0 = anacrolix default (80)
dht_enabled: boolean;
encryption_enabled: boolean;
}
max_peers_per_torrent mutates at runtime; listen_port / dht_enabled / encryption_enabled take effect on next launch.
GET /api/settings/peer_limits
Response: 200 PeerLimitsDTO.
PUT /api/settings/peer_limits
Body: PeerLimitsDTO. Response: 200 {"ok": true} or 400 if listen_port is outside 0..65535 or max_peers_per_torrent is negative.
Settings: blocklist
BlocklistDTO
{
url: string;
enabled: boolean;
last_loaded_at: number; // unix seconds, 0 if never
entries: number; // newline count of last fetch
error?: string;
}
GET /api/settings/blocklist
Response: 200 BlocklistDTO.
PUT /api/settings/blocklist
{ "url": "https://example.com/blocklist.p2p", "enabled": true }
url is validated against the SSRF allow-list (http/https only, no private/loopback addresses). Disabling clears the in-memory blocklist state and tells the engine to drop its filter. Enabling kicks off an immediate fetch.
Response: 200 {"ok": true} or 400 with the validation error.
POST /api/settings/blocklist/refresh
Re-fetch the configured blocklist URL. Body cap at 50 MiB; 30s timeout.
Responses
200 {"ok": true}on success400if no URL is configured / URL re-validation failed502on upstream failure
Settings: web interface
WebConfigDTO
{
enabled: boolean;
port: number;
bind_all: boolean;
username: string;
api_key: string;
}
api_key is read-only here — rotate it via the dedicated route.
GET /api/settings/web
Response: 200 WebConfigDTO.
PUT /api/settings/web
Body: WebConfigDTO. The server only persists enabled, port, bind_all, and username from the request body — api_key is silently ignored on this route (rotate the key via the dedicated POST /api/settings/web/api_key/rotate endpoint instead). Changing username triggers a server-side RevokeAll so any pre-change cookies stop working. Restarts the underlying remote.Server if the bind/port/enabled state changed.
Response: 200 {"ok": true}.
PUT /api/settings/web/password
{ "password": "<new-plaintext>" }
Hashes with Argon2id, persists, calls RevokeAll. Response: 200 {"ok": true}.
POST /api/settings/web/api_key/rotate
Generates a fresh 32-byte token, persists, returns it.
Response: 200 {"api_key": "<token>"}. The token is base64.RawURLEncoding-encoded (URL-safe alphabet, no padding) — characters are drawn from [A-Za-z0-9-_].
Settings: updater
UpdaterConfigDTO
{
enabled: boolean;
channel: "stable" | "beta";
last_checked_at: number; // unix seconds
last_seen_version: string; // last "available" version observed
}
GET /api/settings/updater
Response: 200 UpdaterConfigDTO.
PUT /api/settings/updater
Body: UpdaterConfigDTO (server only persists enabled + channel). Validates channel is "stable" or "beta".
Response: 200 {"ok": true} or 400 {"error": "channel must be stable or beta"}.
POST /api/updater/check
Forces a release check and returns the result.
Response: 200 UpdateInfoDTO
{
available: boolean;
latest_version: string;
asset_url: string;
asset_filename: string;
checked_at: number; // unix seconds
current_version: string; // build-time version
}
500 with {"error":"updater disabled"} if AttachUpdater was never called (test/headless builds).
POST /api/updater/install
Runs the install with SHA-256 manifest verification. On success, fires the desktop “update installed” notification (if a Notifier is attached and the user hasn’t disabled it).
Response: 200 {"ok": true} on success; 500 on failure.
GET /api/version
Response: 200 {"version": "v0.2.9"}. The build-time version string.
Schedule rules
Time-of-day bandwidth rules. When any rule’s window overlaps now, its limits override the global LimitsDTO. When multiple enabled rules match the current time, the engine picks one — the current implementation defers to the rule query order from SQLite. Treat the conflict-resolution behavior as undefined and avoid configuring overlapping rules.
ScheduleRuleDTO
{
id: number;
days_mask: number; // bit 0 = Sunday, bit 6 = Saturday; 127 = all days
start_min: number; // minutes since midnight, 0..1439
end_min: number; // minutes since midnight; if < start_min, wraps midnight
down_kbps: number;
up_kbps: number;
alt_only: boolean; // when true, just enables alt-speed instead of overriding
enabled: boolean;
}
GET /api/schedule_rules
Response: 200 [ScheduleRuleDTO, ...].
POST /api/schedule_rules
Body: ScheduleRuleDTO (id is ignored). Response: 200 {"id": <int>}.
PUT /api/schedule_rules
Body: ScheduleRuleDTO. Response: 200 {"ok": true}.
DELETE /api/schedule_rules/{id}
Response: 200 {"ok": true}.
RSS feeds
FeedDTO
{
id: number;
url: string; // http(s); SSRF-validated
name: string;
interval_min: number; // minutes between polls; 0 → service default
last_polled: number; // unix seconds, 0 if never
etag: string; // last ETag seen, used as If-None-Match
enabled: boolean;
}
GET /api/feeds
Response: 200 [FeedDTO, ...].
POST /api/feeds
Body: FeedDTO (id ignored). URL validated through validateFetchURL.
Response: 200 {"id": <int>}, or 400 with the validation error.
PUT /api/feeds
Body: FeedDTO. Same URL validation as create.
Response: 200 {"ok": true} or 400.
DELETE /api/feeds/{id}
Response: 200 {"ok": true}.
RSS filters
Filters live under a feed and gate which items get auto-added.
FilterDTO
{
id: number;
feed_id: number;
regex: string; // POSIX/Go regexp; empty = match all
category_id: number | null;
save_path: string; // empty = inherit category default → global default
enabled: boolean;
}
GET /api/feeds/{feedID}/filters
Response: 200 [FilterDTO, ...].
POST /api/filters
Body: FilterDTO (id ignored). Response: 200 {"id": <int>}.
PUT /api/filters
Body: FilterDTO. Response: 200 {"ok": true}.
DELETE /api/filters/{id}
Response: 200 {"ok": true}.
WebSocket
A single upgrade route, not a REST endpoint:
GET /api/ws
Auth: cookie OR ?key=<api-key> query param. Origin pinned to Host.
The server pushes Envelope-shaped JSON frames at ~1 Hz; clients send nothing (reads are drained for liveness only).
See WebSocket for the full frame catalog.