Skip to content

Latest commit

 

History

History
1413 lines (1137 loc) · 62.6 KB

api-v2-guide.md

File metadata and controls

1413 lines (1137 loc) · 62.6 KB

WaveFlow native API v2 integration guide

This guide explains how a native, web or automation client uses WaveFlow's JSON API. The generated contract remains the source for exact schemas:

  • GET /openapi.json — OpenAPI document;
  • GET /reference — interactive Scalar reference;
  • RFC-003 — durable synchronization semantics.

Examples use https://music.example.com as the server and placeholders such as ACCESS_TOKEN, TRACK_UUID and DEVICE_UUID. JSON field names are case-sensitive. Public identifiers are UUIDs and timestamps are Unix milliseconds.

Base URL and probes

Do not append /api/v2 when saving the server URL. The base URL is the origin:

https://music.example.com

Unauthenticated probes:

curl https://music.example.com/health
curl https://music.example.com/ready

/health proves that the process responds. /ready additionally verifies SQLite access; scan progress and FFmpeg capability do not affect readiness.

Correlating a request

Every response carries x-request-id, minted by the server, and that value is what its logs record for the request. Quote it when reporting a problem and an operator can find the request.

An x-request-id you send is ignored — dropped before anything reads it, and replaced. Correlation across a reverse proxy still works, in the other direction: record the id the response came back with rather than imposing one, which is what a proxy in front of a server it does not name for should do anyway.

From a browser on another origin, do not send it at all. The header is exposed so that fetch can read the id off the response, and deliberately not allowed on the way in, so a request carrying it fails its preflight rather than being quietly ignored.

First-run setup

On a new data directory, GET /api/v2/setup returns {"required":true}. The embedded browser UI can then create the first administrator. The setup write is unauthenticated but requires an exact same-origin Origin header:

curl -X POST https://music.example.com/api/v2/setup \
  -H "Origin: https://music.example.com" \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"replace-with-at-least-12-characters"}'

It returns 201 with {"user_id":"USER_UUID"} and is rejected after setup has completed. Operators may instead use the account create-admin CLI command shown in the main README.

Authentication choices

Native username/password session

Use the web password, not the dedicated Subsonic password:

curl -X POST https://music.example.com/api/v2/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "listener",
    "password": "correct horse battery staple",
    "device_name": "My integration"
  }'

The response has this shape:

{
  "access_token": "wfa_...",
  "refresh_token": "wfr_...",
  "token_type": "Bearer",
  "expires_in": 900,
  "user": {
    "id": "USER_UUID",
    "username": "listener",
    "role": "user"
  },
  "device_id": "DEVICE_UUID"
}

Send the access token on protected requests:

curl https://music.example.com/api/v2/libraries \
  -H "Authorization: Bearer ACCESS_TOKEN"

Access tokens are short-lived. Refreshing rotates the refresh token; replace the stored token pair atomically and never reuse the old refresh token:

curl -X POST https://music.example.com/api/v2/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"REFRESH_TOKEN"}'

Revoke the current access session with:

curl -X POST https://music.example.com/api/v2/auth/logout \
  -H "Authorization: Bearer ACCESS_TOKEN"

Long-lived API token

An operator can create a token for automation:

cargo run -- token create --actor admin --username listener --name "Home automation"

The command prints the plaintext once. Use it directly as a Bearer token. It has no refresh flow and must be revoked administratively when no longer needed.

The token is written to standard output on its own, and everything else the command says goes to standard error — so it can be captured without being read off a terminal that would then keep it. Create the file closed first: the shell opens it before the command writes anything, and a redirection into a file that already exists truncates it without touching its mode, so a umask alone would not rescue one left readable by an earlier run.

install -m 600 /dev/null token
cargo run -- token create --actor admin --username listener --name "Automation" > token

install is a POSIX tool and has no PowerShell equivalent, so on Windows create the file, strip its inherited permissions, and redirect into it — the redirection truncates that file rather than making a new one, so the access control list set here survives:

Remove-Item -ErrorAction Ignore token
New-Item -ItemType File token | Out-Null
icacls token /inheritance:r /grant:r "${env:USERNAME}:(R,W)" | Out-Null
cargo run -- token create --actor admin --username listener --name "Automation" > token

Use PowerShell 7 or later. Windows PowerShell 5.1 writes UTF-16 through >, which is not what anything reading the token back will expect.

Browser session

Browser clients use /api/v2/web/auth/login, /refresh and /logout. Login requires an exact Origin matching the server origin. The response exposes only the short-lived access token to JavaScript; the rotating refresh token is an HttpOnly, SameSite=Strict cookie.

Refresh and logout require all of the following:

  • credentials/cookies enabled on the request;
  • the exact accepted Origin;
  • the readable waveflow-csrf cookie copied into the X-WaveFlow-CSRF request header.

Set WAVEFLOW_PUBLIC_URL=https://music.example.com behind a reverse proxy so origin validation and Secure cookies use the public HTTPS origin. External browser clients also need their exact origins in WAVEFLOW_ALLOWED_ORIGINS. Wildcards are rejected.

Authorization Code + PKCE for desktop

Native interactive clients should use the system browser:

  1. Generate a 43–128 character PKCE verifier and keep it private.
  2. Compute challenge = base64url_no_padding(sha256(verifier)).
  3. Generate and retain an unpredictable state value.
  4. Open the embedded UI route below in the system browser:
https://music.example.com/authorize?client_id=my.desktop&redirect_uri=http%3A%2F%2F127.0.0.1%3A49152%2Fcallback&code_challenge=CHALLENGE&code_challenge_method=S256&state=STATE&device_name=WaveFlow%20Desktop
  1. After login and consent, validate state on the redirect and exchange the returned code:
curl -X POST https://music.example.com/api/v2/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "code": "AUTHORIZATION_CODE",
    "code_verifier": "ORIGINAL_VERIFIER",
    "client_id": "my.desktop",
    "redirect_uri": "http://127.0.0.1:49152/callback"
  }'

Only S256 is accepted. Supported redirect shapes are loopback HTTP (127.0.0.1, [::1] or localhost), HTTPS, and reverse-domain private-use schemes such as com.example.player://auth. There is intentionally no client registry or client secret. An authorization code expires after ten minutes and is consumed by its first token request, including a failed request; restart the authorization flow instead of retrying a spent code.

Catalogue

All catalogue queries are filtered by the authenticated account's library memberships. An inaccessible or foreign UUID normally returns 404, not a distinguishing permission error.

# Visible libraries
curl https://music.example.com/api/v2/libraries \
  -H "Authorization: Bearer ACCESS_TOKEN"

# Tracks in one library; offset defaults to 0 and limit to 100
curl "https://music.example.com/api/v2/libraries/LIBRARY_UUID/tracks?q=bjork&offset=0&limit=100" \
  -H "Authorization: Bearer ACCESS_TOKEN"

# Search, across every library the account sees or narrowed to one
curl "https://music.example.com/api/v2/search?q=bjork&offset=0&limit=100" \
  -H "Authorization: Bearer ACCESS_TOKEN"
curl "https://music.example.com/api/v2/search?q=bjork&library_id=LIBRARY_UUID" \
  -H "Authorization: Bearer ACCESS_TOKEN"

# Details
curl https://music.example.com/api/v2/tracks/TRACK_UUID \
  -H "Authorization: Bearer ACCESS_TOKEN"
curl https://music.example.com/api/v2/albums/ALBUM_UUID \
  -H "Authorization: Bearer ACCESS_TOKEN"
curl https://music.example.com/api/v2/artists/ARTIST_UUID \
  -H "Authorization: Bearer ACCESS_TOKEN"

# An artist's most played tracks, for this account; limit defaults to 10
curl "https://music.example.com/api/v2/artists/ARTIST_UUID/top-songs?limit=10" \
  -H "Authorization: Bearer ACCESS_TOKEN"

GET /api/v2/songs/by-genre takes a required genre and pages through it; GET /api/v2/songs/random draws a selection in SQL, with optional genre, from_year and to_year. Both match the genre on its canonical name like every other genre filter, and both are the native form of a Subsonic method (getSongsByGenre, getRandomSongs) resolving through the same service.

GET /api/v2/artists/{artist_id}/top-songs answers that artist's most played tracks for the calling account, ordered by play_count and then by title. It is the native form of getTopSongs, through the same service.

It is computed from local listening history alone — play_event — and not from any remote notion of popularity, because the server queries no remote source. Three consequences worth knowing before building on it:

  • a track the account has never played is absent, not last, so a fresh account gets [] rather than a discography in an arbitrary order;
  • the count is per account, like play_count everywhere else, so two members of one library get two different answers;
  • a track counts for an artist two ways: it credits the artist in its own artist role, or its album is credited to the artist as album artist. The second is what keeps a record whose files credit performers per track from having a page full of albums and an empty most-played list.

limit defaults to 10 and is bounded like every other listing (1 <= limit <= 500). An artist the account cannot reach answers 404, indistinguishably from one that does not exist.

GET /api/v2/search applies offset to all three kinds, and accepts artist_offset, album_offset and song_offset to page one of them on its own — which is what a client that has exhausted the artists but not the songs needs, and what search3 has always allowed.

It also accepts library_id, since 2026-09-14, and narrows all three kinds with it. Absent means every library the account can see, so this parameter narrows without becoming mandatory. The index itself is server-wide: what the parameter restricts is the rows a match resolves to, which is where tenancy already lived.

This brings the native surface level with the façade rather than ahead of it. search2/search3 have honoured musicFolderId all along, through catalog_search — a separate path with its own three queries, sharing the index and the projections but not the method. The frozen contract is therefore untouched by this change.

Browse and search pages accept offset >= 0 and 1 <= limit <= 500. GET /api/v2/albums and /artists additionally accept an optional library_id. A SongItem contains stable id, optional album_id and artist_id, metadata, artwork_hash, and the full-file BLAKE3 full_hash. It also carries what the file is — codec, suffix, size, bitrate, duration_ms — and the decoded audio properties sample_rate, channels and bit_depth, the per-account play_count and last_played_at, and the structured artists (every credit in tag order, each {id, name}) and genres lists, plus the tag fields musicbrainz_id (the MusicBrainz recording identifier), bpm, sort_name, comment, the isrc list and the four replay_gain_* measurements, the moods list and explicit_status. artist/artist_id stay the display string and the primary credit, so a client that only wants one name needs no change.

Each tag field is filled by the first scan that runs after the release adding it, and reads empty before that — the album and artist musicbrainz_id are the most recent, so a library scanned for the earlier tag fields still needs one more pass to carry them.

An AlbumItem carries song_count and duration_ms for the whole album, so a listing never has to load the tracks to size it, plus is_compilation, play_count, last_played_at and musicbrainz_id. An ArtistItem carries musicbrainz_id too. An AlbumItem also carries artists and genres, derived from its available tracks.

Those two are release and artist identifiers, not the recording identifier a SongItem carries under the same name, and neither is read from a single file. Tracks of one album routinely disagree — a library assembled over years holds files tagged against different releases of the same record — so an album takes the identifier most of its available tracks agree on, recomputed at the end of every scan, with ties falling to the earliest disc and track so two scans of unchanged files answer the same thing. An artist takes it from the tracks it is the first credit of, because the tag is one value on a file that may credit several artists.

Album discovery

GET /api/v2/albums orders and filters in SQL. sort takes the same ten values as the Subsonic type parameter — both surfaces resolve to the same query — so a home screen is one request rather than a full catalogue page-through:

sort Result
alphabeticalByName (default) By title, case-insensitive
alphabeticalByArtist By album artist, then title
newest Most recently added first
highest Rated albums only, best first
frequent Played albums only, most played first
recent Played albums only, most recently played first
starred Favorited albums only, most recently starred first
random Shuffled
byYear Within from_year/to_year, ascending
byGenre Albums holding at least one track of genre

byGenre requires genre and answers 422 without it, rather than silently returning an unfiltered catalogue. Genre matching is on the canonical form, so hip hop, Hip-Hop and HIP HOP are one genre. For byYear, supplying the bounds reversed (from_year=2020&to_year=2000) returns the range in descending order, matching Subsonic. An unknown sort is a 422.

# Recently added, first page
curl "https://music.example.com/api/v2/albums?sort=newest&limit=20" \
  -H "Authorization: Bearer ACCESS_TOKEN"

# One decade, oldest first
curl "https://music.example.com/api/v2/albums?sort=byYear&from_year=1990&to_year=1999" \
  -H "Authorization: Bearer ACCESS_TOKEN"

GET /api/v2/genres lists the genres the account can see with their song and album counts, optionally narrowed by library_id. Counting groups by canonical name, so one genre spelled differently across libraries is a single row:

curl https://music.example.com/api/v2/genres \
  -H "Authorization: Bearer ACCESS_TOKEN"
[{ "name": "Ambient", "song_count": 128, "album_count": 11 }]

Artwork accepts either an artwork_hash or an authorized track, album or artist ID:

curl https://music.example.com/api/v2/artwork/ARTWORK_HASH \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  --output cover.jpg

The two forms cache differently, because they are not the same resource.

Addressed by an artwork_hash, the hash is the content: the URL can never answer differently, and it carries Cache-Control: private, max-age=31536000, immutable so a client stops asking altogether.

Addressed by a track, album or artist ID, the lookup resolves whichever cover that entity carries now — a rescan finding new embedded art moves it. Those carry Cache-Control: private, no-cache and stay revalidatable. Both forms send an ETag of the artwork hash and honour If-None-Match, so revalidating an alias costs a 304 rather than a second transfer of the same bytes. A client that wants the cheap form should read artwork_hash off the song and address the canonical URL.

Neither form is public, and neither will become so: the route is authenticated and tenant-checked, and two accounts whose libraries hold the same cover share its hash — a shared cache keyed on the URL would hand one tenant's artwork to another with no credential presented. A private cache is the client's own and loses nothing by the restriction.

Lyrics return embedded or UTF-8 .lrc/.txt sidecar content. Synchronized line starts are milliseconds; plain lines omit start:

curl https://music.example.com/api/v2/tracks/TRACK_UUID/lyrics \
  -H "Authorization: Bearer ACCESS_TOKEN"

Playback

Clients able to send headers can stream directly:

# Original bytes, including HTTP Range support
curl "https://music.example.com/api/v2/tracks/TRACK_UUID/stream?format=raw" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Range: bytes=0-1048575" \
  --output part.bin

# Opus transcode at 96 kbit/s, seeking 30 seconds into the source
curl "https://music.example.com/api/v2/tracks/TRACK_UUID/stream?format=opus&bitrate=96&offset_ms=30000" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  --output track.ogg

Valid formats are raw, mp3 and opus. Byte ranges apply to originals and completed cached transcodes. A live transcode uses temporal offset_ms and a chunked response; it does not implement arbitrary output-byte ranges.

Seeking a live transcode through offset_ms abandons the stream that was filling the cache, and the stream that replaces it is not the whole track. The seek therefore also has the server transcode the whole track into its cache behind it, at the lowest priority there is: only when a transcode slot is free beyond the one it takes, and never against the account's own limit. The next play of the track is then a cached file with byte ranges. A server too busy to spare the slot skips it and a later seek asks again; a server configured with a single transcode slot never has one to spare.

What that costs was measured on 2026-09-12 against a real library, rather than assumed: a track of six minutes forty at 96 kbit/s was encoded whole and committed about a second after the seek's own stream had finished. The extra slot is held for seconds, not for the length of the track.

Correcting a track's tags

PATCH /api/v2/tracks/{track_id} writes corrections that survive a rescan without touching the file. full_hash therefore never moves, so a client holding a content-based link to the track still holds it afterwards.

curl -X PATCH https://music.example.com/api/v2/tracks/TRACK_UUID \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title":"Misspelled Title","year":1998}'

The body is a partial patch, and every field has three states:

In the body Effect on that field's correction
absent left exactly as it is
null removed — the field shows what the file says again
a value set

So {} changes nothing, and correcting the title never touches a comment that another client corrected. A blank string reads as null: a track with no title is not a correction worth storing.

# Remove the year correction, keep every other one.
curl -X PATCH https://music.example.com/api/v2/tracks/TRACK_UUID \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"year":null}'

Until #177 this route was wholesale: the body was the complete set, and a field left out was erased. That dropped whatever a client did not mention, including corrections made by somebody else. A client that always sends every field it edits, null included, behaves identically under both; one that relied on omission to clear a correction must now send null.

GET /api/v2/tracks/{track_id}/overrides answers the corrections on their own, beside what the file says, for an editor that has to show which is which.

Correctable: title, sort_title, year, track_number, disc_number, musicbrainz_recording_id, comment, and the two lists artists and genres.

{ "artists": ["Corrected Performer"], "genres": ["Ambient"] }

The lists are lists, not the ;-joined string a file carries. That form exists because a tagger writes names however it likes and the mapper has to guess where one ends; a correction arrives already separated, so re-parsing it would give back the ambiguity it was made to settle — and would lose any name holding the separator. An empty list is a correction meaning the track credits nobody.

Sending null for a list removes the correction and restores what the file's tags say — which the server can only do by re-reading the file, since the correction replaced those rows in the catalogue. A track whose file cannot be read at that moment refuses the whole request rather than half-clearing it. Leaving the list out keeps the correction and reads nothing.

Correcting artists displaces only the track's own artist credits. A composer or a conductor comes from the file and is untouched, which is why a correction does not flatten the other twelve roles.

Album and album artist are not correctable, and not by oversight: album_id is derived from them, so changing one moves the track to a different album — minting one if it does not exist and orphaning the old — rather than relabelling it. That is re-identification, and it needs its own decision.

A correction belongs to the library, not to the account that made it: every member sees it, and only a member who may already spend the owner's disk on a rescan may write one. A listener gets 404, like every refusal that would otherwise confirm what a caller cannot reach. Corrections are announced on the library feed above, not in the user journal.

Learning that a catalogue changed

/api/v2/sync/changes converges user state — favourites, ratings, play history, playlists, shares, bookmarks, the queue. It says nothing about the catalogue, and never has.

GET /api/v2/libraries/{library_id}/events?after=&limit= is the counterpart for library state. Its cursor is a different sequence and advances for different reasons, so it is a separate route with a separate cursor: a rescan must not move a client's position in its own user journal.

{
  "events": [
    {
      "cursor": 41,
      "entity_type": "track",
      "entity_id": "…",
      "action": "upsert",
      "payload": { "full_hash": "…" },
      "changed_at": 1756142400000
    }
  ],
  "next_cursor": 41,
  "has_more": false,
  "purged_through": 0
}

purged_through is the highest cursor retention has already cut away. A cursor strictly below it is refused with 409, and the only way back from there is the snapshot — so subtract it from the cursor you hold to know how much slack you have, and spend that slack deliberately. Resyncing on a network you like, while the application is idle, is a different experience from resyncing at the moment the server refuses you.

It is what was purged, not what survives: a feed whose oldest surviving row sits at a high cursor has lost nothing, it merely started late. Measure against the cursor you hold, not against next_cursor — that one is the end of the page just served, so it tells you the margin you will have after applying it.

A track upsert carries the file's full_hash, and it is the only place on the wire that does. A file retagged outside the API keeps its track id while its bytes move — the scan's skip test compares hashes, so different bytes make it apply the track again. A client holding a content-based link has no other way to learn its link went stale.

Membership is enforced in the query itself, not by a check the query then trusts. A caller who is not a member of the library gets 404, not 403, and a member who loses access stops receiving on the next request — there is no subscriber list to fall out of step.

A cursor below what has been purged answers 409: the feed refuses to hand back a surviving tail that would look like a successful catch-up, and the client re-reads the catalogue instead. The line it compares against is a recorded watermark of what was removed, not the oldest row that survives — a feed whose first event sits at a high cursor has not lost anything, it started late, and the two are indistinguishable from the rows alone.

See RFC-007 for the reasoning.

When a transcode is refused

Transcoding is bounded twice: once for the whole server and once per account. GET /api/v2/transcode/status reports both ceilings alongside active, so a client can size its own concurrency before asking rather than discovering the limit by being refused:

{ "available": true, "active": 1, "global_limit": 4, "per_user_limit": 2 }

Over either ceiling the stream route answers 429 with Retry-After. Honour it — do not fall back to format=raw. A 429 means transcoding capacity is saturated, and the original is several times the bytes of the transcode that was refused: falling back replaces a bounded CPU cost with an unbounded one on the network, over the very link that made transcoding desirable, at the moment every other client is doing the same. Retry with jitter and a bounded number of attempts; drop to raw only after those are spent, only where the link can carry it, and say so in the interface.

Browser media elements cannot attach a Bearer header. Mint a sealed ticket, then resolve the returned relative URL against the same server origin:

curl -X POST https://music.example.com/api/v2/tracks/TRACK_UUID/stream-ticket \
  -H "Authorization: Bearer ACCESS_TOKEN"
{
  "url": "/api/v2/stream/SEALED_TICKET",
  "expires_at": 1787000000000
}

The default ticket lifetime is one hour and is configurable with WAVEFLOW_STREAM_TICKET_TTL_SECS. Cache the URL only until expires_at. Redeeming a ticket rechecks current library access. An invalid, expired or revoked-access ticket returns 404.

A track's canvas

A canvas is a short video loop attached to a track and played without sound over it (RFC-009). A person gives it, so no scan finds one, and it lives in a store of the server's rather than in the library's folder.

Route Purpose
PUT /api/v2/tracks/{track_id}/canvas Place a loop, replacing the one the track carried
DELETE /api/v2/tracks/{track_id}/canvas Take it away
GET /api/v2/tracks/{track_id}/canvas The loop the track carries now; revalidate it
GET /api/v2/canvas/{canvas_hash} A loop by its content, immutable under that URL
POST /api/v2/tracks/{track_id}/canvas-ticket A URL a <video> element can play
GET /api/v2/canvas-stream/{ticket} What that URL serves

Placing one takes an owner or manager of a library whose operator has set accepts_canvas. The body is the file itself, whole, under the route's own ceiling (WAVEFLOW_CANVAS_MAX_BYTES, 4 MiB by default). The server reads the bytes rather than the name: it keeps an mp4 or a webm carrying a video stream that runs no longer than WAVEFLOW_CANVAS_MAX_DURATION_SECS, and takes any soundtrack out before storing it. The answer names what was stored:

{
  "url": "/api/v2/canvas/BLAKE3_HEX",
  "hash": "BLAKE3_HEX",
  "format": "webm",
  "byte_size": 312004
}

A refusal is 404 when the track is missing, the account may not place a canvas or the library takes none — the three are not told apart — 409 when the library's canvas quota is full, 413 over the size ceiling and 422 for a file that is not a short loop. Removal is not gated on accepts_canvas, so closing a library to loops never strands the ones it holds; removing from a track that carries none answers 404.

The ticket works like the audio one, and neither opens the other. Minting it checks the link, so it is also how a client asks whether a track has a canvas: a track without one answers 404.

User-data mutations and idempotency

User data written through the native API and Subsonic façade uses the same domain services. For every logical mutation, generate one UUID and keep it stable across transport retries:

X-WaveFlow-Operation-Id: OPERATION_UUID
X-WaveFlow-Device-Id: DEVICE_UUID

X-WaveFlow-Device-Id is optional, but when present it must belong to the authenticated account. X-WaveFlow-Operation-Id is also optional; omitting it makes the server generate an ID and therefore loses client-side replay safety. Replaying the same operation and normalized intent is safe. Reusing an operation ID for another target or payload returns 409 with code conflict.

Example playlist creation:

curl -X POST https://music.example.com/api/v2/playlists \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-WaveFlow-Operation-Id: OPERATION_UUID" \
  -H "X-WaveFlow-Device-Id: DEVICE_UUID" \
  -d '{"name":"Road trip","track_ids":["TRACK_UUID"]}'

Playlist updates remove zero-based positions first, then append add:

{
  "name": "Road trip 2026",
  "comment": null,
  "public": false,
  "remove_indexes": [3, 1],
  "add": ["TRACK_UUID"],
  "clear": ["comment"]
}

Omitting an optional field leaves it unchanged. To erase a playlist comment, name comment in clear. Shares use the same rule for description and expires_at.

Playlists from another player

POST /api/v2/playlists/import takes the file itself as the body — not JSON. The encoding of a playlist file is part of the problem (.m3u is frequently not UTF-8), so the bytes have to arrive as bytes; a JSON envelope would have forced the caller to decode them first, which is the one decision it cannot make correctly. The server reads UTF-8 when the bytes are UTF-8 and Windows-1252 otherwise, and says which it used in the response.

# What it would do, without doing it
curl -X POST "https://music.example.com/api/v2/playlists/import?dry_run=true" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: audio/x-mpegurl" \
  --data-binary @"Evening.m3u"

# And the import, under a name of your choosing
curl -X POST "https://music.example.com/api/v2/playlists/import?name=Evening" \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: audio/x-mpegurl" \
  --data-binary @"Evening.m3u"

The answer is a report, and reading it is the point:

{
  "playlist": { "id": "...", "name": "Evening", "songs": [] },
  "name": "Evening",
  "encoding": "windows-1252",
  "entries": 11,
  "resolved": 8,
  "unresolved": [
    { "line": 10, "entry": "common.flac", "title": null, "reason": "ambiguous" }
  ]
}

unresolved is not an error list to ignore. An import that silently produced a shorter playlist would leave nobody able to tell that a third of a collection stayed behind, so every entry the catalogue could not place comes back with its line in the file, its text as written and one of four reasons:

reason What it means
no-match No path and no credit in this catalogue matched. A track in a library the account cannot see answers this too — identically to one that does not exist.
ambiguous Several tracks matched and nothing in the entry chooses between them.
not-a-path The target is not a path into a library — a web radio, most often.
duplicate It resolved to a track the playlist already holds; a playlist carries a track once.

dry_run=true answers the same report and creates nothing, which is what lets a client show the losses before they happen: it answers 200 with "playlist": null, where a real import answers 201.

How an entry is matched. A playlist entry is a path relative to wherever the file lived, or absolute on a filesystem that is not the server's, or Windows-shaped with backslashes, or a percent-encoded file:// URL. All four shapes reduce to segments, and what they share with a stored path is a trailing run of them — so the file name selects the candidates and the deepest agreement on the parent folders decides between them. Comparison folds ASCII case, like every other catalogue match. An entry no path matches gets one last rung: its #EXTINF credit, matched as Artist - Title against the catalogue, exactly and only when it names exactly one track.

name wins over the file's own #PLAYLIST directive; with neither, the request is refused (422) rather than storing a nameless playlist. library_id narrows resolution to one library. A file naming more entries than a playlist may hold (10 000) is refused before any of the reads.

GET /api/v2/playlists/{id}/export is the other direction, and the one /api/v2 route that does not answer JSON: the resource is a file, so wrapping it in an envelope would make every consumer unwrap it before writing it to disk. It answers an extended .m3u8 — UTF-8, #EXTM3U, #PLAYLIST, one #EXTINF and one library-relative path per track — with a Content-Disposition naming the playlist. Errors still answer JSON.

curl "https://music.example.com/api/v2/playlists/PLAYLIST_UUID/export" \
  -H "Authorization: Bearer ACCESS_TOKEN" -OJ

What this server writes, this server reads: an exported playlist re-imports with nothing unresolved, which is asserted rather than assumed — including for the two names the format cannot carry naively. A path is written with a ./ prefix and its % escaped as %25, because a line beginning with # is a directive by the format's own definition — a track called #1 Hit.flac at a library's root would otherwise be read back as a comment and vanish without being reported — and an unescaped % would come back percent-decoded.

Other user-data routes:

Purpose Routes
Playlists GET/POST /api/v2/playlists, GET/PATCH/DELETE /api/v2/playlists/{id}
Playlist files POST /api/v2/playlists/import (the file as the body), GET /api/v2/playlists/{id}/export
Favorites GET /api/v2/favorites, `PUT/DELETE /api/v2/favorites/{track
Ratings GET /api/v2/ratings, `PUT /api/v2/ratings/{track
Playback activity POST /api/v2/scrobbles, GET /api/v2/history, GET /api/v2/now-playing
Queue GET/PUT /api/v2/queue (track_ids allows repeated tracks; maximum 400)
Shares GET/POST /api/v2/shares, PATCH/DELETE /api/v2/shares/{id} (1–400 tracks)

POST /api/v2/shares returns the bearer URL at creation and on an authenticated idempotent replay. Later list, snapshot and update responses omit it because only its hash is stored. Preserve the first response if the URL must be shown again. Public access uses GET /share/{token} and needs no account credential.

Synchronization

Start or recover from a full snapshot:

curl https://music.example.com/api/v2/sync/snapshot \
  -H "Authorization: Bearer ACCESS_TOKEN"

Persist the returned cursor, then page changes in ascending order:

curl "https://music.example.com/api/v2/sync/changes?after=CURSOR&limit=100" \
  -H "Authorization: Bearer ACCESS_TOKEN"

Apply every event idempotently, advance to next_cursor, and continue while has_more is true. Limits are 1–500. After durable local application, ACK the device cursor:

curl -X PUT https://music.example.com/api/v2/sync/ack \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_id":"DEVICE_UUID","cursor":CURSOR}'

GET /api/v2/sync/socket?after=CURSOR upgrades to a Bearer-authenticated WebSocket. A text frame is only a wake-up notice:

{"cursor": 1234}

Always fetch /sync/changes after a notice. The server sends Ping frames every 30 seconds and closes a connection that does not answer before the next heartbeat. If changes returns 409 with code=cursor_expired, discard the local projection, obtain a new snapshot and resume from that snapshot's cursor. Do not confuse it with code=conflict.

Bookmarks

One playback position per account and track, for audiobooks and long-form listening. GET /api/v2/bookmarks lists them, most recently changed first. PUT /api/v2/bookmarks/{track_id} takes {"position_ms": 180000, "comment": "chapter two"}; PUT rather than POST because the track names the resource, so calling it again moves the existing bookmark rather than adding a second, and omitting comment clears it rather than keeping the old one. DELETE /api/v2/bookmarks/{track_id} succeeds whether or not a bookmark was there: the caller asked for the track to carry none, and it does not.

A bookmark on a track that has become unavailable, or in a library the account has lost, stops being listed rather than being returned pointing at nothing. These are the same domain methods behind the Subsonic getBookmarks/createBookmark/deleteBookmark, so the two surfaces cannot disagree, and bookmarks reach /api/v2/sync/changes under the bookmark entity type like every other piece of user data.

Internet radio

One list of stations per account. GET /api/v2/radio-stations lists them by name; POST /api/v2/radio-stations takes {"name": "Radio Nova", "stream_url": "https://nova.example.org/live", "homepage_url": null} and answers 201 with the station. GET, PUT and DELETE /api/v2/radio-stations/{station_id} read, replace and remove one; a station belonging to another account answers 404, like everything else that is not yours.

PUT and not PATCH, deliberately. The body carries every field, so leaving homepage_url out clears it. That is what the Subsonic updateInternetRadioStation does — it requires the name and the address on every call — and both surfaces are the same domain method, so an absent field cannot mean two different things depending on the door.

The server never connects to a station. The address is validated as absolute http or https, stored as typed and handed back; the player is what reaches the station. That is the whole security answer for the feature: a station is a URL somebody supplies, and a server that fetched one would be a request forger pointed at whatever it can reach. Two consequences worth designing for:

  • A clear-text station cannot play on a page served over HTTPS. The browser refuses it as mixed content, and nothing the server could do changes that; the web client says so before the address is saved.
  • A station that is offline fails in the player, not in a response. The server has no way to know, and will not pretend to.

An address that is not absolute http/https answers 422, as does a blank name. A blank homepage_url means none: "" and an absent field both clear it, because a form with the box left empty sends one or the other and means the same thing by both. The stream address is required and gets no such reading. A ceiling of 500 stations per account answers 409: the request is well formed and the collection is full.

Stations are account configuration rather than playback state, so they sit beside the API tokens and the scrobbling links and do not appear in /api/v2/sync/changes. The journal's entity_type vocabulary is CHECK-constrained, and admitting a new one is a contract change the desktop client has to be ready for.

External scrobbling

A listen recorded by POST /api/v2/scrobbles can also be forwarded to ListenBrainz, Maloja or Last.fm. Nothing leaves the server until two things are true at once: the operator has declared a destination, and the account has authorised one.

The operator names instances; the account picks a name. A base URL is a deployment setting and never a member's, so a client never sends one. GET /api/v2/scrobble-destinations publishes what this server knows:

[
  { "provider": "listenbrainz", "destination": "default", "available": true },
  { "provider": "maloja", "destination": "alice", "available": true },
  { "provider": "maloja", "destination": "bob", "available": true },
  { "provider": "lastfm", "destination": "default", "available": false,
    "unavailable": "browser_journey_needs_https" }
]

unavailable is a case, not a sentence, and it is present only when available is false. It was finished English prose before 2026-09-15, which every client printed verbatim — including one that ships in two languages, so a French reader was told in English what to change in a configuration file. The wording belongs to whoever is doing the telling. Two cases, and both name something an operator can change:

Case What is missing
no_application_configured WAVEFLOW_SCROBBLE_LASTFM_API_KEY and _SECRET are unset, so there is no Last.fm application to speak for.
browser_journey_needs_https WAVEFLOW_PUBLIC_URL is not an https address, so there is nowhere to bring a browser back to. The command-line journey below needs no public address and still works.

Treat an unrecognised case as "this server did not say why" rather than as a blank: a server is free to be newer than the client reading it.

Several instances of one recipient are ordinary — Maloja self-hosts, and on a household server everybody has their own. Addresses are never published: a member does not need one, and no link stores one.

Linking and unlinking name the pair:

curl -X PUT https://music.example.com/api/v2/scrobble-links/maloja/alice \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"secret":"THE-API-KEY"}'

PUT because the instance names the resource: presenting a second token leaves one link, not two. Listens already queued stay attached to the authorisation they were queued under and are never submitted under the new one. DELETE /api/v2/scrobble-links/{provider}/{destination} withdraws it and succeeds whether or not anything was linked. There is no implicit default: a path without an instance is refused rather than attached to the first one.

Last.fm authorises instead of pasting, because it hands out no secret a person can hold. POST /api/v2/scrobble-links/lastfm/authorize/{destination} answers where to send them and sets the cookie the return is checked against:

{ "authorize_url": "https://www.last.fm/api/auth/?api_key=…&cb=…", "expires_in": 720 }

That answer also sets an HttpOnly cookie scoped to the return path, and the return is refused without it. A client served from the same origin as the API — the built-in web client is — needs nothing more. One served from a different origin must make this call with credentials: "include", or the browser will drop the cookie and the journey will fail at its last step.

Send the browser to authorize_url. Last.fm brings it back to GET /api/v2/scrobble-links/lastfm/callback/{state}, which finishes the link and redirects to an address carrying no token. The journey lasts twelve minutes, is good once, and needs both the address and the cookie — so it has to be completed in the browser that opened it. It requires an operator's Last.fm application and an https WAVEFLOW_PUBLIC_URL; without either, the destination listing says so rather than letting a link fail later. Pasting a session key to PUT still works, for a diagnosis.

That https address is this route's requirement, not Last.fm's. A server without one can still link an account, from its own command line. Nothing comes back to the server, so no public address is needed — only the application. The listing keeps saying available: false, which is the truth about this API and the reason it is worded as the journey's precondition.

waveflow-server scrobble authorize --actor admin --username listener --destination default

It prints an address to open and a request token to bring back. Open the address as that account, approve it there, then hand the token to the second command through WAVEFLOW_LASTFM_TOKEN — the variable --token-env names, and which it can change.

The token goes through the environment for the reason an API token does: it is a credential, and whoever exchanges it first is who the Last.fm session ends up belonging to. So do not type it as VAR=value command, which puts it in the shell history. Read it instead, which echoes nothing and records nothing:

read -rs WAVEFLOW_LASTFM_TOKEN && export WAVEFLOW_LASTFM_TOKEN
waveflow-server scrobble exchange --actor admin --username listener --destination default
$env:WAVEFLOW_LASTFM_TOKEN =
  (Read-Host -AsSecureString | ConvertFrom-SecureString -AsPlainText)
waveflow-server scrobble exchange --actor admin --username listener --destination default

ConvertFrom-SecureString -AsPlainText needs PowerShell 7 or later, as the API token recipe above does. These are two ways and not the way: a secrets manager or a sourced-then-deleted file serve as well, which is why the command prints the token and names the variable rather than prescribing either.

What a link reports is a state, never an echo.

curl https://music.example.com/api/v2/scrobble-links \
  -H "Authorization: Bearer ACCESS_TOKEN"
[{ "provider": "maloja", "destination": "alice", "health": "degraded",
   "pending": 12, "retrying": 3, "uncertain": 1,
   "oldest_pending_at": 1757000000000, "last_success_at": 1756990000000,
   "last_failure": "rate_limited" }]

healthy cannot mean "the token is still good": a valid link with three thousand listens waiting since this morning is broken in every sense that matters. degraded says the link answers but the queue is not moving — an ambiguous listen waiting for a person, or the oldest wait past the configured threshold. broken is a refused credential, or a destination this server can no longer reach under the name the link was made against.

An ambiguous listen is answered by its owner, not by the server. A submission whose fate is unknown — the connection broke after the request left — is never retried automatically: a duplicate in a public listening history is worse than a gap, and only the person can weigh that.

Purpose Routes
Listed GET /api/v2/scrobble-queue/uncertain
Thrown away DELETE /api/v2/scrobble-queue/uncertain/{entry_id}
Sent again POST /api/v2/scrobble-queue/uncertain/{entry_id}/retry
[{ "id": "8f0e…", "provider": "maloja", "destination": "alice",
   "played_at": 1757000000000, "attempts": 1,
   "last_failure": "ambiguous", "updated_at": 1757000000500 }]

An entry names the pair, because the recipient alone does not identify where the listen may already be: a household server carries several instances of one destination, and a person cannot weigh a duplicate without knowing which of their profiles would hold it.

It carries no title and no artists — what this API publishes is a state, never an echo of what was heard. played_at is what identifies the listen to a person, and it is the same instant GET /api/v2/history reports for that play, so a client resolves the track by matching the two. Two plays at one instant match nothing: guess there and the question is put about the wrong listen.

last_failure is ambiguous when the adapter could not tell whether the submission arrived, and interrupted when a previous process left the row claimed and this one recovered it. Those are the only two an entry in this listing can carry.

Retrying is granted once per entry and returns the new entry's id. The ambiguous one stays in the record exactly as it happened: erasing it would falsify the only trace explaining why the destination may hold the listen twice. A second call answers 404, and so does an entry belonging to somebody else — once the single acceptance is spent, the entry stops being findable at all.

Finished entries — sent, rejected, abandoned, cancelled, discarded — are kept for thirty days by default and then purged. Entries still waiting, and ambiguous ones nobody has answered, are never purged.

Administration and scans

Admin Bearer tokens can manage:

Purpose Routes
Users GET/POST /api/v2/admin/users, PATCH/DELETE /api/v2/admin/users/{username}
Subsonic credential PUT/DELETE /api/v2/admin/users/{username}/subsonic-credential
Libraries GET/POST /api/v2/libraries
Membership PUT/DELETE /api/v2/libraries/{library_id}/members/{user_id}
Scans POST /api/v2/libraries/{library_id}/scans, GET /api/v2/scans/{scan_id}, GET /api/v2/scans/{scan_id}/events
API tokens GET/POST /api/v2/admin/users/{username}/tokens, DELETE /api/v2/admin/users/{username}/tokens/{token_id}

An account manages its own clients without any of this, and changes or switches off its own Subsonic password: see its own clients and its own Subsonic password below.

Starting a scan needs more than membership. A scan walks the owner's files and takes the instance's write lock, so POST /api/v2/libraries/{library_id}/scans is reserved to the owner and manager roles; a listener gets 404, the same answer as for a library that does not exist. Reading the catalogue is unaffected. | FFmpeg status and transcode headroom | GET /api/v2/transcode/status | | Library change feed | GET /api/v2/libraries/{library_id}/events |

Creating a library starts its first scan and returns both library_id and scan_id. The scan event route uses Server-Sent Events and still requires the Bearer token — so EventSource, which sends no headers, cannot open it; read the response body of a fetch instead.

Both of its frames carry the same object, the one GET /api/v2/scans/{scan_id} answers: an opening snapshot event, then a progress event per step. A watcher replaces what it holds and never has to tell the two apart.

event: snapshot
data: {"id":"…","library_id":"…","status":"running","total_files":164,"processed_files":0,
       "added":0,"updated":0,"moved":0,"skipped":0,"unavailable":0,"errors":0,
       "current_path":null,"message":null}

event: progress
data: {"id":"…","library_id":"…","status":"running","total_files":164,"processed_files":61, …}

Builds before 2026-09-15 sent a different object for progress, naming those same three fields scan_id, total and processed while the ten counters beside them kept their names. A client bound to the snapshot therefore showed its totals as absent from the first progress frame on, and its per-outcome counters correctly — which reads as a display bug rather than as two shapes on one stream. There is one shape now.

GET /api/v2/libraries answers, for each library the account belongs to, its role and accepts_uploads. The flag says whether the library takes files at all. It is the operator’s decision, made with waveflow library set-uploads and never through the API, and every member sees it: the role says who may upload — owner or manager — and the flag says whether anyone can. A client that reads it offers an upload only where the server would take one, instead of hashing a whole file to be told library_closed.

accepts_canvas, on the same list, is the same kind of decision for canvases, made with waveflow library set-canvas. It is a separate door: a library closed to files may take loops, and the reverse.

Setting a Subsonic credential is administrative, because it is what opens the Subsonic facade to an account at all. Changing or switching off one that already exists is not: see its own Subsonic password below.

Setting a Subsonic credential returns a new API key exactly once:

curl -X PUT https://music.example.com/api/v2/admin/users/listener/subsonic-credential \
  -H "Authorization: Bearer ADMIN_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password":"a separate app password"}'

API tokens

Long-lived tokens for scripts and integrations, as opposed to the session a person signs into or the Authorization Code flow a native client uses. They were a first-class table with scopes, expiry and revocation whose only entry point was a shell on the host; issuing one is now a route.

curl -X POST https://music.example.com/api/v2/admin/users/scripts/tokens \
  -H "Authorization: Bearer ADMIN_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"nightly backup","scopes":["catalog:read"]}'

The response carries the record and a secret beginning wfapi_. Only its SHA-256 hash is stored, so the secret appears there and never again: the listing returns names, scopes and timestamps, and a caller who loses a token issues another rather than reading it back.

Its own clients

The routes above are administrative, and they are not the only way in. An account lists, registers and revokes its own tokens at /api/v2/api-tokens — a plain self-scoped resource like /api/v2/bookmarks, needing no administrator:

curl https://music.example.com/api/v2/api-tokens \
  -H "Authorization: Bearer ACCESS_TOKEN"

curl -X POST https://music.example.com/api/v2/api-tokens \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"the kitchen speaker","scopes":[]}'

curl -X DELETE https://music.example.com/api/v2/api-tokens/TOKEN_UUID \
  -H "Authorization: Bearer ACCESS_TOKEN"

This is safe because a token is never a promotion: the account's role is checked apart from the credential's scopes, so a scope narrows and cannot widen. An ordinary account issuing a token that names admin gets a token that is still refused on every administrative route.

A credential cannot mint one wider than itself. An empty scopes list means "everything this account has", so a token already restricted to write asking for an empty list is refused with 403 — that would be an escalation inside a single account. It may name scopes it holds, and only those. An unrestricted caller — a signed-in session, or a token naming no scope — may ask for anything.

A token belonging to another account answers 404 on the revoke, not 403: naming it must not confirm that it exists.

A credential minted this way expires. WAVEFLOW_API_TOKEN_TTL_SECS, a year by default and 0 for never. The reason is this route: before it, registering a client needed the instance administrator, so a session taken for the fifteen minutes of its access token could read a library and nothing more. Now it can register a credential — and expires_at has been a column honoured by the lookup since the table existed, and was never written.

And never later than the credential that asked. A token expiring tomorrow minting one that expires in a year, and that one another, would make the deadline decorative after a single hop. So a request arriving on an API token gets the earlier of the policy and the caller's own expiry — the scope rule applied to time, since a credential hands on no more than it holds in either currency.

A session carries no such cap, deliberately: it is short-lived and refreshes, and authorising something longer-lived than itself is part of what it is for. Capping by it would make every client a person registers die in fifteen minutes.

The administrative route is deliberately not bounded by that setting. An operator provisioning a service account is a different act from a person clicking a button in their settings: it carries an audit entry, it needs a role no stolen session can reach, and a token that quietly stops working in a year is an operational surprise an operator did not ask for.

Scopes are enforced on every route. A token issued with a non-empty scopes list is restricted to it, whatever the account behind it may do. Two scopes are checked:

Scope Admits
write any mutation: playlists, favorites, ratings, the queue, bookmarks, shares, scrobbles, scans
admin the administrative routes, and everything write admits

Reading needs no scope, so a token naming neither is read-only. That is a statement about reading and not about GET: GET /api/v2/uploads/{session_id} asks for write, because a transfer's state belongs to the flow that writes it, and a catalog:read token is refused there. Every operation that can answer 403 declares it in openapi.json, so a generated client has the branch without having to know which reads are really writes. Minting a credential needs no scope either — it needs the absence of one: the authorization code flow at POST /api/v2/oauth/authorize is refused to any token carrying a scope list at all, because the session it returns is unrestricted and nothing on the grant records what asked for it. A narrowed credential must not be able to mint a broader one. Pairing a device is done from a session, which is what the flow is for. A scope this server does not know grants nothing, which is why catalog:read reads and does no more: there is no vocabulary to learn, only these two names to use.

A token issued without scopes is unrestricted and carries the account's full authority. That is what the CLI has always produced, what tokens created before this release hold, and what sessions and Authorization Code grants carry, so nothing that works today stops working.

The check happens where the caller is resolved, not in each handler, so a route cannot be added without choosing what it needs — the compiler asks. This matters because the previous release stored scopes, returned them from the API and printed them from the CLI while reading them nowhere.

Minting a token for another account is administrative, and these routes are that: naming a username is the administrative act, and the answer is the same from the CLI and from the API. An account mints, lists and revokes its own through /api/v2/api-tokens and needs nobody — see its own clients above for why that is safe and what bounds it.

This paragraph used to say that an account could not mint one for itself at all, on the reasoning that a token carries the authority of the account it belongs to and so who may create one is a question about the instance. The first half is true and the conclusion did not follow: a token carries that authority and never more, because the role is checked apart from the scopes. So the question is about the account, and the sentence was keeping a listener from seeing which devices read their own library.

DELETE revokes one on either path; the token stops authenticating immediately, and revoking it again answers 404, because it is already not working.

The token create CLI command remains, for bootstrapping an instance that has no administrator session yet. Both paths go through the same service.

Its own Subsonic password

The dedicated Subsonic password is the key to the facade, and it is typed into third-party clients, on phones — the ordinary way an application password leaks. Its owner can change it or switch it off:

Route Effect
GET /api/v2/subsonic-credential whether this account holds one, and since when
PUT /api/v2/subsonic-credential replaces the password and the API key
DELETE /api/v2/subsonic-credential switches it off
# null when the account holds none, rather than a 404
curl https://music.example.com/api/v2/subsonic-credential \
  -H "Authorization: Bearer ACCESS_TOKEN"

curl -X PUT https://music.example.com/api/v2/subsonic-credential \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password":"a replacement of at least twelve characters"}'

curl -X DELETE https://music.example.com/api/v2/subsonic-credential \
  -H "Authorization: Bearer ACCESS_TOKEN"
{ "created_at": 1757000000000, "updated_at": 1757900000000 }

There is no POST, and that is the design rather than an omission. An account holding no credential cannot reach the facade at all, so creating one does not narrow a door already open — it opens one. Whether every account may open that door onto the server is the operator's decision, and these routes do not take it. Rotating or switching off a credential that already exists takes nothing from the operator: the door is open, and these change the key or shut it. So PUT on an account holding none answers 404 and creates nothing.

The API key moves with the password. A leaked wfsk_ key authenticates on its own, so rotating one half would answer "my key has leaked" with a new password and the same working key. The new key is returned once, like every other secret here; only its hash is stored. Every Subsonic client of that account stops working until the new password is entered in each of them — which is the point, and worth saying in an interface before the button is pressed.

And the rotation is refused to a credential that could not hand on what it returns, with 403. A wfsk_ key carries neither scopes nor an expiry, because the facade reads neither — so unlike an API token it cannot be narrowed to match the credential that asked for it, and the only honest bound is on the caller:

  • a scoped caller is refused, since a token restricted to write would walk out holding an unrestricted key on the facade;
  • an expiring caller is refused, since a token good until tomorrow would walk out holding a key that never stops. That is the same escalation in the other currency, and the one the self-minted token answers with min(policy, caller) — there is no min to take on a key carrying no deadline at all.

A signed-in session passes both, which is where the button lives.

Switching it off is bounded by neither, deliberately: it hands on no authority, it takes some away. A narrowed or expiring credential closing a door its own account holds is the one act always safe to allow, and refusing it would leave a compromised account unable to act on the news with the credential it has in hand. Switching off a credential that is not there answers 404.

The Subsonic changePassword method is not this route and still requires an administrator. It is part of the frozen v2.0-beta contract, and the native surface is where a new rule belongs.

Errors and retry rules

Native JSON errors use:

{"code":"validation_error","message":"The request is invalid"}
HTTP Code Client action
401 unauthorized Authenticate again; never infer whether an account exists.
403 forbidden Stop or request the required role/origin.
404 not_found Treat the resource as missing or inaccessible.
409 conflict The operation ID was reused for another intent; reconcile and use a new ID only for a genuinely new operation.
409 cursor_expired Discard the sync projection and take a fresh snapshot.
422 validation_error Correct the request; retrying it unchanged cannot succeed.
429 media response Back off and honour Retry-After; a transcode concurrency limit was reached.
503 service_unavailable Retry with bounded exponential backoff.

Never branch on status 409 alone: its two codes require opposite recovery paths.

Endpoint inventory

The tables above describe common workflows. The authoritative route and schema inventory is always the running server's /reference or /openapi.json; use that contract when generating a client.