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.
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.
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.
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.
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"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" > tokeninstall 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" > tokenUse PowerShell 7 or later. Windows PowerShell 5.1 writes UTF-16 through >,
which is not what anything reading the token back will expect.
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-csrfcookie copied into theX-WaveFlow-CSRFrequest 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.
Native interactive clients should use the system browser:
- Generate a 43–128 character PKCE verifier and keep it private.
- Compute
challenge = base64url_no_padding(sha256(verifier)). - Generate and retain an unpredictable
statevalue. - 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
- After login and consent, validate
stateon the redirect and exchange the returnedcode:
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.
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_counteverywhere 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
artistrole, 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.
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.jpgThe 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"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.oggValid 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.
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.
/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.
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 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 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.
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" -OJWhat 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.
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.
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.
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.
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 defaultIt 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 defaultConvertFrom-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.
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"}'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.
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.
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
writewould 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 nominto 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.
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.
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.