This is the complete REST surface mounted by backend/remote.Mount on the /api prefix. Every route except POST /api/login and GET /api/bootstrap is gated by AuthGate. Every state-changing route except bearer-keyed callers is gated by OriginGuard.

On the mosaicd daemon the interface is multi-user: AuthGate resolves each request to an account and the response is scoped to it. Mutations are checked against that account’s role and permission flags — an unmet permission returns 403 {"error": "forbidden"}. The desktop build runs with a single implicit account and never returns 403.

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. OriginGuard allows requests that have neither an Origin nor a Referer header — SameSite=Strict on 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} — sets mosaic_session cookie
  • 400 body decode failure
  • 401 {"error": "invalid credentials"}
  • 429 {"error": "too many login attempts"}Retry-After: 12 is 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.

GET /api/bootstrap

Public (no auth). Lets the SPA learn which build it is talking to before login — the daemon shows the Users settings pane, the desktop build shows Web Interface.

Response: 200

{
  flavor: "daemon" | "desktop";
  version: string;
  multi_user: boolean;   // true for the daemon
}

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;
  sequential: boolean;    // earliest-first piece priority (see Sequential download)
  queued: boolean;        // capped by queue limits
  verifying: boolean;     // recheck in progress
  files_missing: boolean; // on-disk pieces vanished between sessions
  access: "owner" | "editor" | "viewer"; // the caller's access level
}

access is the requesting account’s access level on this torrent. Admins (and the desktop build) always see owner. It gates which mutations the caller may perform: viewer is read-only, editor may pause/resume/recheck and change priorities, owner may additionally delete and share.

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>"}
  • 400 invalid 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 .torrent blob
  • save_path (string, optional) — defaults to the global default

Responses

  • 200 {"id": "<info-hash>"}
  • 400 parse / 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}.

POST /api/torrents/sequential

Toggle sequential piece download (earliest-first request order) for a torrent. See Sequential download for what this does to the piece picker.

Request body

{ "infohash": "string", "enabled": true }

Response: 200 {"ok": true}. State persists in the torrents table and is re-applied on startup.

POST /api/torrents/{id}/trackers

Add a single tracker URL to an existing torrent. The new URL is announced to immediately. User-added trackers persist across restarts.

Request body

{ "url": "https://tracker.example.org/announce" }

Response: 200 {"ok": true}.

DELETE /api/torrents/{id}/trackers

Remove a tracker URL from a torrent. The URL is passed in the body, not the path, to avoid having to percent-encode ://. Anacrolix’s ModifyTrackers is used to atomically rebuild the announce list without the removed URL. Removal is persisted — even trackers from the original metainfo stay removed across restarts.

Request body

{ "url": "https://tracker.example.org/announce" }

Response: 200 {"ok": true}. No error if the URL isn’t present.

POST /api/torrents/url

Add a torrent by URL — either a magnet: URI or a direct https://…torrent link. The .torrent URL path issues a 10 MiB-capped fetch through the same SSRF-validated HTTP client the RSS poller uses, then passes the bytes to the engine. The magnet path is equivalent to POST /api/torrents/magnet.

Request body

{ "url": "magnet:?xt=urn:btih:… | https://example.org/file.torrent", "save_path": "/optional" }

Response: 200 {"ok": true} (note: this route doesn’t currently return the new torrent’s id; query GET /api/torrents if you need it).


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" populates files[]
  • "peers" populates peers_list[]
  • "trackers" populates trackers[]
  • "overview" and "speed" are accepted by the request body but are inert — scopeForTabs doesn’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;
  completed_at?: number;    // unix seconds, omitted if not yet complete
  paused: boolean;
  completed: boolean;
  files_missing: boolean;
  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;
  upnp_enabled: boolean;          // automatic UPnP/NAT-PMP port forwarding
}

max_peers_per_torrent mutates at runtime; listen_port / dht_enabled / encryption_enabled / upnp_enabled take effect on next launch. upnp_enabled defaults to true (matching anacrolix’s own default) and toggles the NoDefaultPortForwarding flag on the underlying torrent.Client at startup.

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: seeding

Global default stop-conditions for seeding. Per-torrent overrides at GET/PUT /api/torrents/{id}/seed_policy. See Seed limits for the semantics.

SeedingDefaultsDTO

{
  ratio_limit:    number | null;  // bytes_up / bytes_done; null = no limit
  time_min_limit: number | null;  // minutes since first completed-and-unpaused tick; null = no limit
}

GET /api/settings/seeding_defaults

Response: 200 SeedingDefaultsDTO.

PUT /api/settings/seeding_defaults

Body: SeedingDefaultsDTO. Response: 200 {"ok": true}.

SeedPolicyDTO

{
  use_global:     boolean;        // true = clear override, fall back to global defaults
  ratio_limit:    number | null;
  time_min_limit: number | null;
}

use_global: false with both *_limit fields null means “no limit, ignore globals” — distinct from use_global: true.

GET /api/torrents/{id}/seed_policy

Response: 200 SeedPolicyDTO.

PUT /api/torrents/{id}/seed_policy

Body: SeedPolicyDTO. Writing use_global: true deletes the per-torrent override row; writing the other shapes persists a small JSON blob on the torrents row. Response: 200 {"ok": true}.

A 30-second background ticker checks every completed, unpaused torrent against its effective policy (per-torrent beats global) and pauses any that have exceeded their ratio or time limit.


Per-torrent rate limits

Cap a single torrent’s download/upload speed independent of the global cap. The effective speed is the minimum of all applicable caps. See Per-Torrent Rate Limits for behavior notes (duty-cycle limiter, coarse-grained at low caps).

TorrentRateLimitsDTO

{
  down_kbps: number; // KB/s, 0 = unlimited
  up_kbps:   number; // KB/s, 0 = unlimited
}

GET /api/torrents/{id}/rate_limits

Response: 200 TorrentRateLimitsDTO. Requires viewer-or-higher access on the torrent.

PUT /api/torrents/{id}/rate_limits

Body: TorrentRateLimitsDTO. Persists to the torrents row and re-applies to the live engine; the duty-cycle limiter goroutine starts on first non-zero set and exits when both axes are 0 again. Requires editor-or-owner access. Response: 200 {"ok": true} or 400 if any field is negative.


Settings: watch folder

Auto-add .torrent files dropped into a directory. See Watch Folder for the polling cadence + dedup story.

WatchFolderDTO

{
  path: string;
  delete_after_add: boolean;
  enabled: boolean;
}

GET /api/settings/watch_folder

Response: 200 WatchFolderDTO.

PUT /api/settings/watch_folder

Body: WatchFolderDTO. Persists to settings and restarts the polling goroutine — switching enabled, swapping path, or toggling delete_after_add all take effect on the next poll without a restart. Response: 200 {"ok": true}.

Note: a native directory-picker dialog (PickWatchFolder in the Wails binding) is desktop-only — there’s no remote equivalent for popping an OS dialog on the user’s machine.


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 success
  • 400 if no URL is configured / URL re-validation failed
  • 502 on upstream failure

Users & accounts

Multi-user account management. Meaningful on the mosaicd daemon; on the desktop build there is a single implicit account. All routes require an authenticated session. The admin-only routes return 403 for non-admin callers.

UserDTO

{
  id: number;
  username: string;
  role: "admin" | "user";
  perm_add_torrents: boolean;
  perm_manage_rss: boolean;
  perm_manage_cat_tags: boolean;
  perm_change_settings: boolean;
  perm_share: boolean;
  has_api_key: boolean;
  api_key_hint: string;   // last 4 chars, for display
  password_set: boolean;
  disabled: boolean;
  created_at: number;     // unix seconds
}

GET /api/me

The calling account’s own UserDTO.

POST /api/me/password

Body { "old_password": "...", "new_password": "..." }. Confirms the current password, sets the new one (minimum 8 characters), and revokes the caller’s sessions. Response: 200 {"ok": true}.

POST /api/me/api_key/rotate

Mints a fresh API key for the calling account. Response: 200 {"api_key": "<token>"} — shown once; only a SHA-256 hash and a 4-char hint are stored.

GET /api/users

Admin only. Response: 200 UserDTO[].

POST /api/users

Admin only. Creates an account.

{
  username: string;
  password: string;      // required, minimum 8 characters
  role: "admin" | "user";
  perm_add_torrents: boolean;
  perm_manage_rss: boolean;
  perm_manage_cat_tags: boolean;
  perm_change_settings: boolean;
  perm_share: boolean;
  disabled: boolean;
}

role: "admin" forces every permission on. Response: 200 UserDTO.

PUT /api/users/{id}

Admin only. Same body as create; password is ignored (use the reset route). Refuses any change that would leave zero enabled admins. Response: 200 UserDTO.

DELETE /api/users/{id}

Admin only. The primary admin (id 1) and your own account cannot be deleted. Response: 200 {"ok": true}.

POST /api/users/{id}/password

Admin only. Body { "new_password": "..." } — resets another account’s password and revokes its sessions. Response: 200 {"ok": true}.


Torrent sharing

A torrent is owned by whoever added it; an owner can share it with other accounts at viewer (read-only) or editor (control, no delete/re-share) level. All routes require owner access on the torrent.

ShareDTO

{
  user_id: number;
  username: string;
  access: "owner" | "editor" | "viewer";
}

GET /api/torrents/{id}/shares

Every access grant on the torrent. Response: 200 ShareDTO[].

POST /api/torrents/{id}/shares

Body { "user_id": number, "access": "viewer" | "editor" }. Grants or updates another account’s access. owner cannot be granted this way. Response: 200 {"ok": true}.

DELETE /api/torrents/{id}/shares/{userID}

Revokes an account’s access. An owner grant cannot be revoked here. Response: 200 {"ok": true}.


Settings: web interface

WebConfigDTO

{
  enabled: boolean;
  port: number;
  bind_all: boolean;
  username: string;
  api_key: string;
}

This route configures the optional embedded web server. The username/api_key fields map to the admin account (id 1): username is its login name, and api_key here is the non-secret last-4 hint of its key, not the key itself. On mosaicd, server config and accounts are managed from Settings → Users instead.

GET /api/settings/web

Response: 200 WebConfigDTO.

PUT /api/settings/web

Body: WebConfigDTO. The server only persists enabled, port, bind_all, and usernameapi_key is ignored (rotate via the dedicated route). Changing username renames the admin account and revokes its sessions so 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>" }

Sets the admin account’s password (Argon2id), marks it operator-set, and revokes that account’s sessions. Response: 200 {"ok": true}. Regular accounts change their own password via POST /api/me/password.

POST /api/settings/web/api_key/rotate

Mints a fresh API key for the admin account and returns it once — only a SHA-256 hash is stored. Equivalent to POST /api/me/api_key/rotate when called as the admin.

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
  install_source: "apt" | "appimage" | "manual"; // how the running binary got onto disk
}

install_source is detected at startup. When it’s "apt" the in-app updater goes dormant — the periodic check is skipped, the install button refuses, and the Settings → Updates pane renders a Managed by apt banner. AppImage and manual installs use the in-app updater normally.

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": "v1.5.0"}. 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}.

GET /api/feeds/{feedID}/items

Live-fetch the feed (bypassing the per-feed dedup cache) and return every item with its resolved torrent source. Used by the SPA’s expandable feed-item browser.

Response: 200 [FeedItemDTO, ...]

// FeedItemDTO
{
  guid:        string;
  title:       string;
  pub_date:    string;   // "YYYY-MM-DD" in UTC, empty if missing
  torrent_url: string;   // magnet: URI if present, else https://…torrent, else ""
}

POST /api/torrents/url

Add a torrent from a URL. Accepts both magnet: URIs and direct .torrent URLs (10 MiB-capped fetch). Used by the feed-item Add button — see also the Torrents → POST /api/torrents/url entry above.


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: session cookie OR Authorization: Bearer <api-key> header. The legacy ?key=<api-key> query-param form was removed because URL params leak into Referer headers, reverse-proxy access logs, and browser history. 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.