api reference
Browsing
# What is there? curl -s http://server:7090/api/v1/services # Start at the root, then walk in curl -s http://server:7090/api/v1/browse curl -s "http://server:7090/api/v1/browse/<id>?offset=0&limit=50"
Every service appears under its own name — applemusic, soundcloud, library, radio. The radio service contains Radio Paradise, TuneIn Presets and Custom Streams. There is no Spotify disguise here. That disguise exists because the Loxone clients know exactly one streaming service; it is a translation in that adapter and it stops there.
{ "services": [
{ "id": "applemusic", "name": "Apple Music",
"rootId": "b1.c.category.YXBwbGVtdXNpYw.cm9vdA",
"searchableKinds": ["track", "album", "artist", "playlist"] }
]}
rootId is the id to browse that service's top level — GET /browse/{rootId} — so you can jump straight into one service without walking the root listing. searchableKinds is what its search can actually answer; empty means it cannot search at all, which is why asking radio for tracks returns nothing rather than failing.
GET /browse with no id lists the services themselves, so a client that just wants a tree can start there and never read /services.
Sections
A listing may carry a sections array alongside items, when the provider groups its own children — an Apple Music root does, the browse root does not:
{ "container": { … }, "items": [ … ], "sections": [ { "id": "…", "name": "Recently Added", "items": [ … ] } ], "start": 0, "total": 8 }
sections is not a grouping of items — it is separate content. On the Apple Music root the two do not overlap at all: items holds the eight menu entries you can walk into (Albums, Artists, Playlists…), while sections holds eighty-odd editorial rows (New Releases, Recently Played, Made For You). A client that renders only items shows a menu and silently drops everything the provider actually put on its front page.
So render both, in either order, and expect no duplicates. total counts items; section contents are whole and not paged.
Items
{
"id": "b1.p.track.YXBwbGVtdXNpYzp0cmFjazpiNjRfYVM1WVRVUldaRUpS…",
"name": "A Bird In New York",
"kind": "track",
"browsable": false,
"playable": true,
"service": "applemusic",
"artist": "Eric Serra",
"album": "Léon (Original Motion Picture Soundtrack)",
"duration": 81,
"coverUrl": "https://…/640x640bb.jpg"
}
kind is track, album, artist, playlist, radio, show, episode, category, folder or unknown. Treat the list as open — new kinds may appear and a client must not fail on one it does not know.
browsable and playable are separate questions and both can be true: an album is something to open and something to play, so branch on what the user did rather than inferring from kind.
id is opaque. Feed it back to /browse/{id}, /items/{id} or POST /zones/{id}/play — it round-trips exactly, and the queue routes take it too. Do not parse it: the encoding is not part of this contract, and ids stay valid across restarts and library rescans precisely because they do not encode anything you should rely on.
Paging
start (or offset — both accepted everywhere, and the response field is start) and limit, default 50, max 500. total is the number of children — or null when the provider cannot say, which several streaming providers genuinely cannot. When it is null, keep paging until you get back fewer items than you asked for. A number here is a real count, not an estimate.
Looking one thing up
curl -s http://server:7090/api/v1/items/<id>
For when you have an id but no listing it came from — a deep link, a restored session, an id stored last week. One caveat, stated plainly: a container's own name is not always knowable. Providers name a folder in the listing that contains it, not in the folder itself, so name can come back empty for a container you did not browse into. It is never fabricated.
Search
curl -s "http://server:7090/api/v1/search?q=beatles" curl -s "http://server:7090/api/v1/search?q=beatles&kind=album&limit=10" curl -s "http://server:7090/api/v1/search?q=beatles&service=applemusic,library"
{
"query": "beatles",
"items": { "track": [ … ], "album": [ … ], "artist": [ … ] },
"services": [{ "service": "library" }, { "service": "applemusic" }]
}
Results are grouped by kind, and every item names the service it came from.
kind narrows the search, and it is not merely a filter: a provider that cannot search a kind is not asked for it. Ask for albums and SoundCloud sits it out, because SoundCloud has no album search — check searchableKinds in /services to see what each one offers rather than assuming they are alike. service narrows which providers are asked at all.
services in the response says who answered, with failed: true on any that errored — so a provider outage looks like a partial answer rather than "no matches".
Playlists
Playlists you make here, on the local library. Streaming services keep their own — those are read-only and appear through /browse like any other container.
GET /api/v1/playlists ?start=&limit= → { "items": [ … ], "total": 1 }
POST /api/v1/playlists {"name": "…"} → 201 with the playlist
PATCH /api/v1/playlists/{id} {"name": "…"} → 200, renamed
DELETE /api/v1/playlists/{id} → 204
POST /api/v1/playlists/{id}/items {"id": "<item id>"} append
PATCH /api/v1/playlists/{id}/items {"from": 0, "to": 3} move
DELETE /api/v1/playlists/{id}/items {"position": 0} remove
{ "id": "b1.c.playlist.bGlicmFyeQ.…", "name": "Sunday", "tracks": 5,
"coverUrl": "http://server:7090/music/local/…/cover.jpg" }
A playlist's id is a browse id, so GET /browse/{id} lists its tracks and POST /zones/{id}/play with it plays the lot. There is no separate "read a playlist" route because there does not need to be one.
Two things differ from the queue, and both catch people out:
- Entries are addressed by position, not by an id.
{"position": 0}removes the first track;{"from": 0, "to": 3}moves it. A queue hands out a per-entry id because the same track can sit in it twice and you must be able to say which; a playlist is edited as a list, so the index is the handle. Read the playlist back after a move rather than assuming your own arithmetic matched. - Only library tracks can be added. Handing it an
applemusic:orsoundcloud:item id answers400 invalid-playlist-item. A local playlist stores local audio; a streaming track is not the server's to keep, and a reference that dies when a subscription lapses would be worse than the refusal.
Errors: invalid-name, playlist-not-found, invalid-playlist-item, playlist-item-not-found.
Inputs
Physical inputs — a turntable, a CD player, a MasterLink device — are configured in the admin UI. What is configured is listed here, and there may be none:
{
"inputs": [
{ "id": "linein-ms3h9f42", "name": "BeoSound 9000", "icon": "cd-player",
"controllable": true, "reportsMetadata": true }
]
}
They belong to the server, not to a zone: the same input is selectable from any zone, so switching one is a property of the zone rather than a list under it.
curl -X PUT http://server:7090/api/v1/zones/3/input -d '{"input":"linein-ms3h9f42"}'
source.id reports that same id back once the zone is on it, so what you read is what you can write. source.name is the input's configured name.
controllable is worth branching on. For an input that answers commands — something on a MasterLink bus, say — the ordinary POST /zones/{id}/pause, /next and so on reach the device. For a turntable or a bare jack it is false: selecting it is the whole interaction, and transport commands change nothing audible. reportsMetadata says whether track will ever be more than blank.
icon is a hint for choosing an artwork: line-in, cd-player, computer, imac, ipod, mobile, radio, screen, turntable. Treat the list as open.
There is no verb for leaving an input — selecting something else is how you leave, and the server releases the old source as part of that.
Announcements
# Say something in the kitchen
curl -X POST http://server:7090/api/v1/zones/3/alert \
-d '{"kind":"tts","text":"Dinner is ready","language":"nl"}'
# The doorbell, everywhere at once
curl -X POST http://server:7090/api/v1/zones/3/alert \
-d '{"kind":"bell","zones":[7,9]}'
# Your own sound
curl -X POST http://server:7090/api/v1/zones/3/alert \
-d '{"kind":"url","url":"http://nas/sounds/washer-done.mp3"}'
An alert interrupts rather than queues: whatever the zone was playing is ducked and picked up again afterwards, and the volume comes from that zone's configured alert level — not its current one, so an announcement is audible in a room someone had turned down.
kind is tts, bell, alarm, fire, buzzer or url. tts needs text and takes an optional language; url needs an http(s) address this server can reach. zones adds more rooms to the same announcement, with the zone in the path leading it — the response lists every room it played in.
volume (0–100) overrides the zone's alert level for one announcement.
alarm and fire keep going until stopped, which is what DELETE is for. The others end by themselves.
A 422 means the alerts layer refused it — most often no text-to-speech provider is set up — with error naming the reason. A 2xx means it started, not that you heard it.
Favourites, recents and groups
A favourite's id is its handle, for renaming, reordering, playing and removing. The Loxone clients also carry a slot and a plus flag — a position in their own button grid — which describe that UI rather than the favourite, so neither appears here. Reordering is simply the order you send.
Recents are read-only apart from clearing: history has no handle to rename or reorder, and source is what you hand back to play.
Grouping answers 200 with the resulting group, not 204. Frame mirroring works between outputs of one protocol, so unless the server allows mixed groups a member on another protocol cannot join — it comes back under rejected with a reason, instead of leaving you to diff what you asked for against the next zone event. An empty members list ungroups; there is no separate verb for leaving.
Output delay
PUT /zones/{id}/output/delay with {"delayMs": 60} declares how much delay a zone's speaker chain adds after its audio output — an amplifier, an active speaker. It is the only output setting this API writes.
It points the opposite way from its name. The client subtracts the value from every timestamp before scheduling playback, so raising it makes that room play earlier, compensating for the delay downstream of it. That is what lines up a room arriving late; a room arriving early has nothing to declare, and the protocol has no negative form (0–5000 at the device). To pull a group together, raise the value on the room that lags — not on the one that leads.
The server keeps its own send-ahead in step: it schedules that client's audio further in advance by the same amount, so the buffer headroom stays what it was. You can see that in output.sync — targetLeadMs grows with delayMs.
The value is persisted and applied live: Sendspin pushes it to the client without restarting the stream, so it is audible immediately and survives a reboot. Out-of-range values are clamped to 0–10000 ms rather than refused, because "as far as it goes" is a real request; a value that is not a number is a 400, because moving a speaker by accident is worse than an error.
One caveat: the device owns this setting. It persists it locally, may keep a different value per audio output, and only honours the command if it advertised support for it — so what you write here is a request. output.sync therefore reports both sides: delayMs is what this server asked for and deviceDelayMs is what the device last declared, or null if it never has.
deviceDelayMs is not a confirmation of delayMs. A device applies the command immediately — that is how the protocol works — and does not mention the value until the next state message it sends for some other reason, so the two differ after every write until something else happens. Do not build a "not applied yet" indicator on the difference; it only tells you that you just wrote.
What it is good for is the case where a device holds a value nobody here asked for, persisted locally for the amplifier it is wired to. Whether a device accepts the command at all is a different question, answered by the supported_commands it advertises.
Pass clientId to target one Sendspin satellite instead of the zone's own output — a subwoofer under a pair of speakers needs its own offset.
PUT /zones/3/output/delay {"delayMs": 60}
-> 200 {"delayMs": 60, "applied": true, "clientId": null}
applied: false means the value was stored but no live output took it: the zone's protocol has no delay, or the named satellite is not configured. That is a success, not a failure — the config is the durable part, and a device connecting later picks it up. There is no GET: the current value is output.sync.delayMs on the zone, and a second spelling of one value is a second thing to keep true. The write publishes a zone.changed, so every other client sees it without polling.
The queue
GET returns a page: start and limit (default 100, max 500) are query parameters, and total is the length of the whole queue so you know whether to ask for more. currentIndex is the entry playing now, or null.
Each entry has an id that identifies the entry, not the track — queue the same track twice and you get two ids. That id is what play, move and id take. source is the same opaque provider id as ApiSource.id, so an entry can be re-queued later.
Clearing the queue needs {"all": true} rather than an empty body: wiping a queue should be something you asked for, not something a missing field happened to mean.
volume takes either an absolute value (0–100) or a signed delta. Use delta for remote-control style stepping: it avoids the read-then-write race that two clients adjusting the same zone would otherwise hit, and volumeLimits.step is the size a step should have.
A zone can be capped: writing above volumeLimits.max lands on the cap rather than where you asked, so render your slider against max instead of against 100.
equalizer takes ten gains in dB, low band first, clamped to -6..+6 — the same range the Loxone app uses. It replies 200 with the applied bands rather than 204, since a clamped value is worth seeing. Unlike the transport verbs it is configuration, so it works on an idle zone too.
If you are an equalizer provider — you run the actual DSP and want the server to reflect it — write here when your own UI changes, and read here to pick up changes made elsewhere. The server does not push your change back to you.
Errors are 4xx with {"error":"…"}: zone-not-found, invalid-json, invalid-volume, invalid-position, invalid-power, invalid-uri, invalid-repeat, invalid-shuffle, invalid-equalizer-bands, invalid-queue-patch, invalid-queue-delete, queue-item-not-found, invalid-target-zone, handoff-not-possible, handoff-failed, invalid-favorite-patch, invalid-favorite-delete, invalid-favorite-order, favorite-not-found, invalid-members, invalid-alert-kind, invalid-text, invalid-zones, invalid-input, input-not-found, invalid-query, method-not-allowed.