REST for Client Developers
Synara speaks two protocols with the same vocabulary: a Kotlin RPC websocket and a plain HTTP/JSON surface. This page is for developers writing a client in any language — a web app, an iOS app, a desktop player, a script — against that HTTP surface. It explains where the routes come from, how parameters and responses are shaped, and how to authenticate; it does not list the routes themselves, because REST_API.md is generated from the same source the server is generated from. Kotlin clients should read CLIENT_KOTLIN_RPC.md instead and use the typed RPC stubs.
Base URL and discovery
Every route is mounted at the root of the server — there is no /api prefix. With the default configuration the server listens on port 8080, so GET /user/me is http://localhost:8080/user/me.
| Resource | What it is |
|---|---|
/swagger |
Swagger UI of the live server. |
/api.json |
OpenAPI document of the live server, including the hand-written routes. |
| REST_API.md | The generated route table in this repository, one section per service. |
GET /handshake/handshake |
Reachability probe; returns secure, sslSupported, apiVersion and uiSchemaVersion. Needs no token. |
The OpenAPI document is the authority for the server you are actually talking to; REST_API.md is the authority for the version in this repository.
Through the proxy
A server that dials out to the standalone proxy is reached at http(s)://<proxyHost>:<port>/<instanceId>/<path>. <path> is the path the server itself uses, so GET /song/search becomes GET /<instanceId>/song/search. The proxy host, port, TLS flag and instance id are served by GET /serverStats/proxyInfo (IServerStatsService.getProxyInfo(), model ProxyInfo with host, controlPort, ssl and id). The websocket for the Kotlin RPC stays at ws(s)://<proxyHost>:<port>/<instanceId>/rpc.
Authentication, request and response bodies, Range requests, HEAD, Server-Sent Events and compression behave as with direct access. Only these paths are reachable:
- every generated route from REST_API.md
- the hand-written
POST /authenticate,POST /refresh-token,POST /register,GET /userInfo,GET /sessionsandDELETE /sessions/{sessionId} OPTIONSfor those paths, which answers the CORS preflight
Everything else answers 404 through the proxy: /swagger, /api.json, /static, the web UI, /mcp, the hand-written /radio/** stream routes and the Subsonic API at /rest/**. A path with a trailing slash is refused as it is on the server itself. A malformed request target answers 400.
The proxy itself answers these statuses, with Access-Control-Allow-Origin: * when the request has an Origin header:
| Status | When |
|---|---|
404 |
the instance id is proxy or instances, which the proxy reserves |
503 |
no server is connected under the id, or the instance already has 128 requests open |
501 |
the connected server does not support HTTP through the proxy |
431 |
the request headers are larger than 65536 bytes |
400 |
the request method is longer than 255 characters |
502 |
the server or the connection to it failed before a response started |
The proxy closes the connection of a client that reads nothing for 10 seconds while response data is waiting. A reverse proxy in front of the Synara proxy must not buffer responses, or Server-Sent Events arrive late.
Authentication
Two credentials exist. Details, lifetimes and the API-key scopes are in AUTHENTICATION.md; the summary a client needs:
- JWT.
POST /authenticatewith{"username": …, "password": …}returns{token, refreshToken, expiresAt}(expiresAtis epoch milliseconds; see API_CONSTANTS.md#authentication for the access token's lifetime).POST /refresh-tokenwith{"refreshToken": …}returns a fresh pair. Use these two hand-written routes. - The generated
POST /auth/authenticate?username=&password=reaches the same service but is not equivalent: the credentials travel in the query string (and therefore into logs and proxies), and a wrong password comes back as400 Invalid username or password— the RPC method throwsIllegalArgumentException, which the REST layer maps to400— where/authenticateanswers401. Handle both codes if you must call it. - Send the token as
Authorization: Bearer <jwt>or as the cookiesynara-auth=<jwt>. The cookie is checked first and wins if both are present. The server never sets that cookie itself — a web client writes it (document.cookie) after logging in. Behind the proxy, scope the cookie withPath=/<instanceId>/, see Through the proxy. - API keys (
?apiKey=, headerX-API-Key, orAuthorization: Bearer <key>) are accepted only by/radio/**and/mcp, plus the Subsonic API at/rest/**, which takes a key with thesubsonicscope in itsapiKeyparameter. They do not work for the generated services.
Routes marked PUBLIC in the route table need no credential at all: the handshake, the /auth/* and /serverStats/* routes, GET /image/imageData/{id}, GET /animatedImage/imageData/{id} and GET /release/releaseImage/{releaseId}.
Behind the proxy several instances share one origin. A cookie written with path=/ is sent to every instance on that proxy, so the token of one server reaches the others. Write it with Path=/<instanceId>/ instead, so the cookie string is synara-auth=<jwt>; path=/<instanceId>/; SameSite=Strict.
CORS allows any origin and any method, but not credentials, so a browser cannot send the cookie cross-origin. Serve a web client from the same origin as the server (or put both behind one reverse proxy) whenever you need cookie authentication — which you do for <audio>, <img> and EventSource, none of which can set a header.
Headers
| Header | Send it | Meaning |
|---|---|---|
Authorization: Bearer <jwt> |
on every authenticated request | See above. |
X-Api-Version |
always | The value of ApiVersion.CURRENT you compiled against — see API_CONSTANTS.md#api-version. Absent means the legacy version, and the server then reshapes responses accordingly. See API_VERSIONING.md. |
X-Ui-Schema-Version |
if you render server-driven UI | Omit it and every component arrives as Fallback. See SERVER_DRIVEN_UI.md. |
Accept-Language |
if you show server text | e.g. de-AT, de;q=0.9, en;q=0.5; the highest-quality language wins, fallback en. |
X-Time-Zone |
optionally, if you show server text | An IANA time zone id such as Europe/Berlin. Times in server-driven UI are shown in this zone. Without it, or with an invalid id, they are shown in UTC. |
Content-Type: application/json |
on requests with a body | Bodies are always JSON. |
The code samples throughout this page and the rest of the docs send X-Api-Version: 6 — the number current when they were written. A lower number still works against a newer server: the server shapes its response down to what that version understands, per API_VERSIONING.md.
JSON conventions
Everything is encoded with one kotlinx.serialization configuration (AppJson), so:
- Unknown keys are ignored on the way in, and defaults are always written on the way out — a field with a default is present in the response, not omitted. A handful of fields are explicitly excluded from that rule and appear only when they are set:
audioandatmosonSong/UserSongare absent whennull, andtagsis absent when empty. - UUIDs are strings,
"7c9e…". Dates and times:Instant,LocalDate,LocalDateTimeandOffsetDateTimeare ISO-8601 strings;PlatformDatefields (expiresAt,userSongCreatedAt, …) are epoch-millisecond numbers;Durationis an ISO-8601 duration string. Timestamps that are documented as "Unix timestamp in milliseconds" are plain numbers. - Sealed hierarchies carry a
typediscriminator with the serial name of the variant ({"type": "Conflict", …},{"type": "tile", …}). The UI vocabulary is described in SERVER_DRIVEN_UI.md. ByteArrayin a response is sent as raw bytes, butByteArrayin a request body is JSON — an array of signed byte numbers (PUT /user/profileImagetakes[-119,80,78,71,…]). There is no multipart upload.
Model field lists live in MODELS.md, anchored per type, e.g. UserSong and PaginatedResponse.
How a route is derived
The REST layer is generated by common-rpc/rest-compiler from the RPC interfaces:
- Prefix: comes from the interface name.
- Verb and path segment: come from the method name;
@RestGet/@RestPost/@RestPut/@RestDelete/@RestPathoverride both. - Path segments:
type/…Idparameters become path segments. - Everything else is bound by type — a primitive as a query parameter, a non-primitive as the JSON body (or, with several non-primitive parameters, one wrapper object with a field per parameter).
The full rule set and the parameter-binding table are in REST_API.md#how-routes-are-derived.
Two worked examples: ISongService.rankedSearch → GET /song/search, and ISongService.setLikeLevel → PUT /song/likeLevel/{id}.
The multi-body case is easy to get wrong. ICoverGenerationService.applyCover(target, params) is POST /coverGeneration/applyCover with
{"target": {"…": "…"}, "params": {"…": "…"}}
— not the bare target object. The route table marks every body parameter with b:, so a route with two b: entries takes the wrapper object. A ? in the route table means the parameter has a default and may be omitted.
Values are parsed strictly: enums case-insensitively by name, booleans as true/false, UUIDs and Instants in their canonical form. Anything else is a 400.
Defaults are the ones declared on the interface: omit a parameter and the server calls the method with its Kotlin default. That also means an omitted parameter is not the same as an explicit empty value.
Pagination
List routes take page (0-based) and pageSize as query parameters and answer with PaginatedResponse:
{"data": [], "page": 0, "total": 1042, "pageSize": 50, "hasNextPage": true}
Page through until hasNextPage is false. The default page size is whatever the interface declares — 50 on most library queries, 200 on the queue, 150 on the artist release feeds, while getRecentReleases returns up to 50 entries per release type per page (folded editions counting once) — so send pageSize explicitly if it matters to you.
Responses and status codes
| Return type of the method | Response |
|---|---|
| a value | application/json |
Unit |
200 with an empty body |
ByteArray |
the raw bytes; Content-Type sniffed from the magic bytes when the payload is an image or a video (plus Content-Disposition: inline), otherwise application/octet-stream |
Flow<ByteArray> |
a raw byte stream |
Flow<T> |
Server-Sent Events |
@RestFileResponse |
a file response with range support, see below |
| Status | When |
|---|---|
200 |
success (206 on a satisfied Range request) |
400 |
a parameter failed to bind, a body was not a JSON object, a required parameter was missing, or the method threw IllegalArgumentException |
401 |
the route needs a user and no valid token was presented |
403 |
the user lacks the capability or admin flag the method requires |
404 |
the method returned null (unknown id, no Atmos variant, …) |
500 |
anything else; the body carries the exception message, and an error escaping the route layer is answered as 500: <cause> |
Error bodies are plain text, not JSON. Treat a 404 as "no such thing" rather than "route does not exist".
Server-Sent Events
Any method returning Flow<T> (other than Flow<ByteArray>) is an SSE endpoint: Content-Type: text/event-stream, Cache-Control: no-cache, one JSON object per data: line, no event names and no ids. The server also sends an SSE comment line, : keep-alive, once on connect and every 5 seconds after that. EventSource and other SSE libraries drop comments on their own, but a hand-written line reader must ignore lines starting with : instead of parsing them. The stream stays open until you close it or the server shuts down; null items are skipped. Reconnect yourself — there is no Last-Event-ID handling.
Whether the first event arrives immediately depends on the stream:
GET /listenBackup/observeStateemits the current state right away.GET /queue/observeQueueemits only after a write, so a fresh subscription stays silent until something actually changes — readGET /queue/queueInfoonce for the initial state.GET /clientRequest/connect?description=,GET /remoteControl/observeStatus/{sessionId}(see STREAMING_AND_PLAYBACK.md) andGET /change/observeChangesbehave like the previous entry, exceptobserveStatusreplays the last known status immediately.
observeChanges only ever names the topic that changed (ONLINE_DEVICES, HOME_CARDS, LISTENS, LISTENBRAINZ_STATUS) and carries no data, so the client reads the actual state with the regular getter — getOnlineDevices, getHomeCards, recentListens/recentArtists/recentAlbums, or ListenBrainz's getStatus.
curl -N -H "Authorization: Bearer $TOKEN" -H 'X-Api-Version: 6' \
'http://localhost:8080/queue/observeQueue'
curl -N -H "Authorization: Bearer $TOKEN" -H 'X-Api-Version: 6' -G \
'http://localhost:8080/clientRequest/connect' \
--data-urlencode 'description={"deviceName":"Desk","capabilities":["REMOTE_CONTROL","REMOTE_VOLUME"]}'
document.cookie = `synara-auth=${token}; path=/; SameSite=Strict`;
const events = new EventSource("/queue/observeQueue");
events.onmessage = (event) => {
const info = JSON.parse(event.data);
console.log(info.version, info.total);
};
var request = URLRequest(url: URL(string: "\(base)/queue/observeQueue")!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("6", forHTTPHeaderField: "X-Api-Version")
let (stream, _) = try await URLSession.shared.bytes(for: request)
for try await line in stream.lines where line.hasPrefix("data: ") {
let payload = Data(line.dropFirst(6).utf8)
handle(try JSONDecoder().decode(QueueInfo.self, from: payload))
}
EventSource cannot send an Authorization header, so in the browser it depends on the synara-auth cookie and therefore on being same-origin.
File routes
Methods annotated @RestFileResponse — streamSong, streamSongAtmos, downloadSong, streamEpisode — are registered for both GET and HEAD, answer with Accept-Ranges: bytes and Content-Disposition: inline; filename="…", and run through Ktor's PartialContent (at most 10 ranges per request), so an ordinary Range: bytes=… request yields 206 with Content-Range. That is what makes seeking work in <audio>, AVPlayer and ExoPlayer.
The header is always plain ASCII:
-
filenameholds the file name, and when the name has non-ASCII characters each of them is replaced by_there. -
A second parameter
filename*then holds the exact name, percent-encoded as UTF-8 (filename*=utf-8''s%C3%A4nger.mp3). -
A client that wants the exact name reads
filename*when it is present andfilenameotherwise. -
HEADreturns theContent-Lengthand the content type the server resolved for the file, without a body; it is the cheapest way to learn a stream's size and its type. -
The GET body's content type is not necessarily that type. Only the HEAD response carries the resolved one; the GET (and ranged
206) response is typed from the file extension by a generic table. MP3, M4A, OGG, WAV and AIFF files do arrive with anaudio/*type, but FLAC and.opusfiles arrive asapplication/octet-stream. The same holds for podcast episodes: the server computesaudio/mpeg,audio/mp4,audio/aac,audio/ogg,audio/flacoraudio/wavfrom the extension, but that value reaches the HEAD response only, and a FLAC or Opus episode isapplication/octet-streamon GET. Learn the type fromHEAD, or from the song'saudio.codec— never from the streamed response. -
If the server cannot resolve a file for the request (a podcast episode it only relays, for instance), the same route falls back to a plain chunked byte stream without
Accept-Ranges; seek with theoffsetquery parameter instead.
Details, content types and the size endpoints are in STREAMING_AND_PLAYBACK.md.
Generated Kotlin clients
The Gradle module :common-rpc:rest-client offers the same services as the RPC stubs over this HTTP surface. For every @Rpc interface the build generates a class in the package dev.dertyp.rpc.rest.client that implements the interface and calls the routes of this page. The name is Rest plus the interface name without its leading I, so IAlbumService becomes RestAlbumService. A small hand-written runtime in the same package (RestClientConfig, RestClient, RestRequest, RestException) does the HTTP work. A Kotlin app can therefore use the IAlbumService type without caring whether the transport is the websocket or HTTP.
val config = RestClientConfig(
httpClient = HttpClient(),
baseUrl = { "http://localhost:8080" },
token = { forceRefresh -> tokenStore.accessToken(refresh = forceRefresh) },
)
val albums: IAlbumService = RestAlbumService(config)
val album = albums.byId(albumId)
baseUrl and token are suspending lambdas that are read on every request.
baseUrlmay carry a path prefix. To reach a server through the proxy, return"https://<proxyHost>:<port>/<instanceId>"built fromProxyInfo. Every call, flow, file method and<method>Urlbuilder then goes through the proxy, see Through the proxy.tokenreceivesforceRefresh = truewhen the server answered401and returnsnullwhen no token is available.headersis an optional block that adds further headers to every request.
Every request carries X-Api-Version with the version the module was compiled against. Bodies and responses are JSON, and routes marked PUBLIC are sent without an Authorization header.
- Errors. A
401is retried once withtoken(true). A second401throwsRestException(401, …). A400throwsIllegalArgumentExceptionwith the text of the response. Every other failure throwsRestExceptionwith the status and the response text, or the status description when the body is empty. A404givesnullfor a method with a nullable return type and aRestExceptionotherwise. - Flows. A method returning
Flow<T>is cold and opens one SSE request each time it is collected. There is no automatic reconnect, because the server sends no resume position. Collect again to start over, and read the current state with the regular getter first where Server-Sent Events says the stream stays silent until something changes. AFlow<ByteArray>method streams the raw response body. - File methods.
streamSong,streamSongAtmos,downloadSongandstreamEpisodereturn a flow that sends itsGETwhen it is collected. It passesoffsetas the query parameter and, whenoffsetis above 0, as aRange: bytes=<offset>-header. It emits chunks ofchunkSizebytes, the last one shorter. A404throwsRestException(404, …)at collection time. - URL builders. Every method
foowith a REST route has a second methodfooUrl(...)that returns the absolute URL of the call as aStringand sends nothing. It takes the path and query parameters of the route in route order. A parameter that has a default may be left out, and the server then applies its default. ForPOST,PUTandDELETEroutes the body is not part of the URL. A route markedPUBLICneeds no token. For every other route the caller adds theAuthorizationheader or thesynara-authcookie itself, as described in Authentication. File routes answer byte ranges, so a player that is given the URL can seek, see File routes.RestClient.url(request)is the runtime function behind the builders and returns a KtorUrl.
val url = RestSongService(config).downloadSongUrl(songId, quality = 320)
- Members without a REST route throw
UnsupportedOperationExceptionwhen called.
Known limits of the parameter encoding:
- A nullable query parameter whose default is not
nullcannot carry an explicitnull. The client omits it, so the server uses the default. - A non-null list or set parameter with a default cannot carry an empty collection, for the same reason.
- An empty string for a required query parameter counts as missing and answers
400.
Worked samples
Log in
curl -X POST http://localhost:8080/authenticate \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"secret"}'
struct AuthResponse: Decodable {
let token: String
let refreshToken: String
let expiresAt: Int64
}
func login(base: URL, username: String, password: String) async throws -> AuthResponse {
var request = URLRequest(url: base.appending(path: "authenticate"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(["username": username, "password": password])
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(AuthResponse.self, from: data)
}
const response = await fetch(`${base}/authenticate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const { token, refreshToken, expiresAt } = await response.json();
The current user
curl -H "Authorization: Bearer $TOKEN" -H 'X-Api-Version: 6' \
http://localhost:8080/user/me
var request = URLRequest(url: base.appending(path: "user/me"))
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("6", forHTTPHeaderField: "X-Api-Version")
let (data, _) = try await URLSession.shared.data(for: request)
let me = try JSONDecoder().decode(User.self, from: data)
const me = await fetch(`${base}/user/me`, {
headers: { Authorization: `Bearer ${token}`, "X-Api-Version": "6" },
}).then((r) => r.json());
Search songs
ISongService.rankedSearch → GET /song/search, with query and explicit required and page, pageSize, liked optional.
curl -G http://localhost:8080/song/search \
-H "Authorization: Bearer $TOKEN" -H 'X-Api-Version: 6' \
--data-urlencode 'query=daft punk' \
--data-urlencode 'explicit=true' \
--data-urlencode 'pageSize=20'
var components = URLComponents(url: base.appending(path: "song/search"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "query", value: "daft punk"),
URLQueryItem(name: "explicit", value: "true"),
URLQueryItem(name: "pageSize", value: "20"),
]
var request = URLRequest(url: components.url!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("6", forHTTPHeaderField: "X-Api-Version")
let (data, _) = try await URLSession.shared.data(for: request)
let page = try JSONDecoder().decode(PaginatedResponse<UserSong>.self, from: data)
const params = new URLSearchParams({ query: "daft punk", explicit: "true", pageSize: "20" });
const page = await fetch(`${base}/song/search?${params}`, {
headers: { Authorization: `Bearer ${token}`, "X-Api-Version": "6" },
}).then((r) => r.json());
Read a stream
curl -I -H "Authorization: Bearer $TOKEN" -H 'X-Api-Version: 6' \
"http://localhost:8080/song/streamSong/$SONG_ID"
curl -r 0-65535 -o head.flac -H "Authorization: Bearer $TOKEN" -H 'X-Api-Version: 6' \
"http://localhost:8080/song/streamSong/$SONG_ID"
var request = URLRequest(url: base.appending(path: "song/streamSong/\(songId)"))
request.httpMethod = "HEAD"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("6", forHTTPHeaderField: "X-Api-Version")
let (_, response) = try await URLSession.shared.data(for: request)
let size = (response as! HTTPURLResponse).expectedContentLength
const head = await fetch(`${base}/song/streamSong/${songId}`, {
method: "HEAD",
headers: { Authorization: `Bearer ${token}`, "X-Api-Version": "6" },
});
const size = Number(head.headers.get("content-length"));
For actual playback the player element does the fetching and cannot carry the header — see STREAMING_AND_PLAYBACK.md.
Common mistakes
- Forgetting a required parameter that looks optional.
expliciton/song/search,/song/allSongs,/song/likedSongsand friends has no default; leaving it out is400 Missing required parameter 'explicit'. The route table marks optional parameters with?. - Forgetting
X-Api-Version. Without it the server treats you as a version-1 client:streamSongAtmosresolves to nothing (404) while songs still carryatmos, WAV and AIFF files are transcoded to FLAC before streaming, title tags are folded back into the song title and album edition tags into the album name, release and album editions arrive as separate entries, and server-driven UI arrives asFallback. The complete list is the feature table in API_CONSTANTS.md#features. - Sending a bare object where a multi-body route expects a wrapper. Two
b:parameters mean one JSON object with a field per parameter. - Expecting JSON error bodies. Failures are plain text.
- Using the header for media.
<audio>,<img>,EventSourceandAVPlayercannot setAuthorization; use thesynara-authcookie on the same origin (behind the proxy withPath=/<instanceId>/), or aPUBLICimage route. There is no signed-URL or token-in-query mechanism for the generated services — API keys only open/radio/**,/mcpand the Subsonic API at/rest/**. - Assuming
404means the route is wrong. It usually means the method returnednull.
Next
- CLIENT_GETTING_STARTED.md — the first ten minutes against a live server.
- REST_API.md — every route, its parameters and its response kind.
- AUTHENTICATION.md — tokens, sessions, API keys and capabilities.
- STREAMING_AND_PLAYBACK.md — audio, images, scrobbling, queue and multi-device state.
- API_VERSIONING.md — what each
X-Api-Versionchanges. - SERVER_DRIVEN_UI.md — rendering the server's component trees.
- README.md — documentation index.