Sonorium has an HTTP API with JSON bodies. Everything the web UI does goes through it, so anything you can do on a screen, a program can do too: start a channel when the alarm goes off, duck the music for a doorbell chime, change the theme from a wall tablet, follow what plays from a dashboard.

Who uses it:

This page describes Sonorium 1.7.1. The complete reference, with every field the Android app uses, is docs/APP_API.md in the repository; sign-in is in docs/AUTH.md. To extend Sonorium from the inside, see Plugin Development.


Base URL and ports

Port Protocol What it serves
8008 HTTP Web UI, API, audio streams. Speakers fetch their streams here.
8808 HTTPS The same web UI and API, encrypted, with Sonorium's self-signed certificate.

Every API path starts with /api:

http://192.0.2.20:8008/api/sessions
https://192.0.2.20:8808/api/sessions

GET /api/info needs no key and tells you the server's ports, name and certificate:

curl http://192.0.2.20:8008/api/info
{
  "id": "00000000-0000-4000-8000-000000000001",
  "name": "Sonorium",
  "version": "1.7.1",
  "api": 1,
  "install": "docker",
  "features": {
    "connection_settings": {"available": true, "enabled": true, "overridable": false},
    "network_speakers": {"available": true, "enabled": true, "overridable": false},
    "space_editing": {"available": true, "enabled": true, "overridable": false},
    "plugin_packages": {"available": true, "enabled": false, "overridable": true}
  },
  "ports": {"http": 8008, "https": 8808},
  "host": "192.0.2.20",
  "hostname": "sonorium.local",
  "login_required": true,
  "pair": true,
  "fingerprint": "6f310f64a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c",
  "plugins": ["integration", "users"],
  "boot_id": "d10435418d284eea8d9f6cdfbd0251be",
  "ui": {"version": "1.7.1", "host_api": 1}
}
Field Type Meaning
id string (UUID) The server's ID. It stays the same for the life of the install.
version string Sonorium's version.
api integer The API version, 1. It goes up only for a change that breaks existing callers; new routes and fields don't change it.
install addon, docker, windows, linux How Sonorium is installed.
features.<name> {available, enabled, overridable} Install features: offered here, switched on now, can be switched on in Settings › Advanced.
ports.http, ports.https integer; https may be null The ports as the network sees them. null: HTTPS is off.
host string The address speakers use to reach Sonorium.
hostname string or null The mDNS name, or null when there is none.
login_required boolean Sign-in is required on the direct ports (the Users plugin's Require login is on).
fingerprint string or null SHA-256 of the HTTPS certificate, 64 lowercase hex characters.
plugins string[] The sign-in plugins that are on: users (accounts) and integration (keys).
boot_id string New each time Sonorium starts. The event stream's ids include it.
ui object The web UI's version, and the version of its hooks for the Android app.

The HTTPS certificate

Sonorium makes its HTTPS certificate on first start and keeps it for 20 years (ECDSA P-256, self-signed). No public authority signed it, so a browser warns about it and a program has to be told to trust it. The certificate names sonorium.local to sonorium-9.local, localhost, 127.0.0.1 and the network addresses the server had when the certificate was made.

Think of the fingerprint as the certificate's serial number: if the number you see matches the one Sonorium reports, you are talking to your Sonorium.

To trust it with curl:

  1. Save the certificate:
    openssl s_client -connect 192.0.2.20:8808 </dev/null 2>/dev/null | openssl x509 -outform PEM > sonorium.pem
    
  2. Print its fingerprint:
    openssl x509 -in sonorium.pem -outform DER | sha256sum
    
  3. Compare it with fingerprint from GET /api/info. They must be the same.
  4. Call the API with the saved certificate, using a name the certificate lists:
    curl --cacert sonorium.pem -H "Authorization: Bearer $KEY" https://sonorium.local:8808/api/sessions
    

On Windows (PowerShell), step 2 is Get-FileHash -Algorithm SHA256 on the DER file; the result is uppercase, the same digits.

Plain HTTP on 8008 is fine on a home network you trust. Keys and passwords sent over 8008 can be read by anyone who can watch that network; use 8808 when that matters.


Authentication

Who may call the API depends on the Users plugin's Require login (Settings › Users & devices):

Require login A call without a key
Off (or the Users plugin is off) Allowed: Sonorium is open to anyone on the network, as before sign-in existed. A key still limits what its caller may do when one is sent.
On Refused with 401, except GET /api/info, the sign-in routes and the audio streams.

Webhook triggers and the event stream always need a key or a token, whether Require login is on or off.

Keys

A key is a password for a program. It allows only the items checked on it, and it can be limited to some channels. Keys start with sk_. A key never manages users or keys.

Making a key (an Admin):

  1. Open Settings in the left menu.
  2. Click Keys.
  3. Click New key (top right).
  4. Type a Name, such as Kitchen tablet.
  5. Pick Expires: In 7 days, In 30 days, In 90 days (the default), In 1 year or Never.
  6. Check what the key may do (the boxes are listed under Abilities).
  7. Under Channels, leave All channels, or click Only these… and check the channels.
  8. Click Make key.
  9. In Key issued, click Copy, then Done. The key is shown once; Sonorium keeps only a hash of it.

⋯ › Edit… changes the name, expiry and boxes; the change applies from the key's next request. ⋯ › Revoke… stops the key at once.

Sending a key, any one of:

Authorization: Bearer sk_EXAMPLE0000000000000000000000000000000000
X-Sonorium-Token: sk_EXAMPLE0000000000000000000000000000000000
?token=sk_EXAMPLE0000000000000000000000000000000000

Use a header. The query form is for devices that can only open a URL; Sonorium masks token= in its log.

KEY=sk_EXAMPLE0000000000000000000000000000000000
curl -H "Authorization: Bearer $KEY" http://192.0.2.20:8008/api/sessions

GET /api/auth/me shows what the key may do:

{"kind": "key", "user": null, "csrf": null, "reset": false,
 "perms": ["an.send", "ch.change", "ch.play", "ch.view", "ev.read", "th.view"],
 "bypass": false, "ingress": false, "web_ui": true, "words": ["forest", "candy", "award", "fancy"],
 "plugins": ["integration", "users"], "starting": false, "closed": false,
 "require_login": true, "has_users": true, "ha_users": false, "zones": false, "needs_setup": false}

A key made by a Home Assistant user who is later switched off in Home Assistant stops working (401, "The user who made this key is switched off. Make a new key.").

Other callers

Caller How it is recognised What it may do
Home Assistant's sidebar (Home Assistant app) Home Assistant signs the person in Their role, from their Home Assistant account (owner and Administrators: Admin; others: User)
A linked phone (the Sonorium app) Authorization: Bearer sd_… Its user's role, less managing users, devices and keys
A key sk_…, as above Only the boxes checked on it, and only its channels when it has a list
A signed-in browser The sonorium_session cookie Its user's role
Nobody recognised Open access while Require login is off; else only the public routes

Browsers and the CSRF token. A program can sign in like a browser: POST /api/auth/login with {"username", "password"} sets the sonorium_session cookie (HttpOnly, SameSite=Strict, Secure on HTTPS). Every change (POST, PUT, DELETE) with that cookie must carry the X-CSRF-Token header, whose value is csrf from GET /api/auth/me; without it the answer is 403 {"detail": "Reload the page and try again", "csrf": true}. A session lasts 30 days, or until it is 14 days unused. Calls with a key or a phone token need no CSRF token. For programs, a key is simpler.

Roles

Admin User
Channels: see, play and stop, change theme, preset, volume and speakers, add and remove Yes Yes
Playlists and sequences: see, run, edit Yes Yes
Themes and presets See, edit, upload, delete See
Speakers Everything See, mute and volume
Settings, plugins, Connection, Users & devices, Keys, announcements Yes No

Abilities

Every route needs one permission. A key has the abilities checked on it; a role is a set of them. The table follows the New key dialog.

Area Box Ability Allows
Channels View ch.view Read channels, now playing, stream URLs, settings, status, sounds, ramps, audio outputs
Channels Play and stop ch.play Play, pause, stop, stop all, listen, report cast devices, leave or rejoin a link
Channels Change theme, preset and volume ch.change Theme, preset, volume, name, speakers muted, Announcements on or off, volume ramps
Channels Change speakers ch.speakers A channel's speakers or speaker group; sending a channel to an audio output
Channels Add and remove ch.add Create and delete channels
Themes and presets View th.view Themes, tracks, track audio, groups, presets, categories, theme export
Themes and presets Edit th.edit Track settings, groups, linked files, presets, categories, favorite, rename, metadata
Themes and presets Upload and delete th.upload Create, upload into, import and delete themes
Playlists and sequences View pl.view Playlists, a theme's sequences and their step settings
Playlists and sequences Run pl.run Start or stop a sequence or playlist on a channel, skip to the next
Playlists and sequences Edit pl.edit Create, change and delete playlists and sequences
Speakers View sp.view Speakers, speaker groups, floors and areas, rescans
Speakers Mute and volume sp.mute A speaker's volume and mute; a channel's speakers-muted switch
Speakers Change settings sp.set Speaker settings, enabled speakers, speakers by address, speaker groups, test sound
Triggers Run triggers tr.run Every plugin's webhook triggers
Events Read events ev.read The event stream and the event catalogue
Announcements Make announcements an.send POST /api/announce, sounds over channels
Plugins Use plugin pages and buttons pg.use Plugin list, plugin pages and their buttons
Settings View st.view Logs, alerts, Home Assistant cards
Settings Change st.change Settings, Advanced values, floors and areas, install features, zones and links

Permissions no key can have: admin.users (users), admin.keys (keys and the older trigger and event-stream tokens), admin.plugins (installing and configuring plugins, schedules, command maps), admin.connection (Settings › Connection), self (linking a phone, your own phones) and device (only a linked phone).

Channel limits. A key limited to some channels (Only these…) sees only those channels in GET /api/sessions and GET /api/now-playing. A channel route for another channel answers 403 {"detail": "Not allowed for this channel"}. Routes that change channels without naming one (creating a channel, stop all) answer 403 {"detail": "Only allowed for some channels"}. POST /api/announce with "all" means the key's channels.


Requests, answers and errors

Errors have an HTTP status and a body:

{"detail": "Session not found"}

A body that fails validation answers 422 with a list:

{"detail": [{"type": "less_than_equal", "loc": ["body", "volume"], "msg": "Input should be less than or equal to 100", "input": 200, "ctx": {"le": 100}}]}
Status Body When
400 {"detail": "…"} A bad value, Invalid JSON body, Expected a JSON object
401 {"detail": "This key is not valid. It may have expired or been revoked."} A wrong, expired or revoked key
401 {"detail": "Sign in first", "login": true} No key, and sign-in is required
401 {"detail": "A valid token is needed"} Triggers and the event stream without a key or token
403 {"detail": "Not allowed"} The key lacks the ability; also any /api/… path Sonorium doesn't have
403 {"detail": "Only an admin can do this"} A User's sign-in or phone on an Admin route
403 {"detail": "Not allowed for this channel"} A channel outside a key's channels
403 {"detail": "Not allowed from another site"} A browser request from another web site without a key
404 {"detail": "Session not found"} and similar Unknown id
404 {"detail": "Not Found"} A route of a plugin that is switched off
409 {"detail": "…"} A conflict: a duplicate name, no free channel slot, nothing to skip, a busy speaker, a group in use
422 a list, as above A field out of range or of the wrong type
429 {"detail": "…"} + Retry-After Rate limited
502 {"detail": "…"} A plugin, a speaker or text to speech failed
503 {"detail": "Sonorium is starting", "starting": true} Retry in a few seconds

Theme, category and /api/channels routes answer errors with both keys, {"detail": "Theme not found", "error": "Theme not found"}, with the status chosen from the message: 404 "… not found", 409 "… already exists", 503 "… not available", 500 "Failed …", else 400.


Rate limits

What Limit Answer
Webhook trigger calls 10 in a burst, then 2 a second, per trigger 429, Retry-After
Wrong trigger tokens 10 from one address, then one every 6 s 429, Retry-After
Sign-in (/api/auth/login, /api/pair/login) 5 failures a minute from one address 429, Retry-After
Phone linking (/api/pair) 5 in a burst, then one every 12 s, per address 429, Retry-After
Event streams open at once 20 per server 503 Too many event streams open
Everything else none

Limits are kept in memory: a restart clears them.


Live events

Sonorium announces what happens as events: a channel started, its volume changed, a sequence moved to its next step, a speaker went offline. A program follows them as Server-Sent Events, a long HTTP answer that never ends, one event at a time, like a news ticker. Use it instead of asking for /api/sessions every few seconds.

GET /api/events/stream · ev.read (Read events)

Query Default Meaning
types * Comma-separated patterns: a type (channel.started), a prefix (channel.*) or *
last_event_id Replay the events after this id (same as the Last-Event-ID header)
max_events 0 Above 0: close the stream after that many events
curl -N -H "Authorization: Bearer $KEY" "http://192.0.2.20:8008/api/events/stream?types=channel.*,sound.*"

What arrives:

retry: 5000

id: d10435418d284eea8d9f6cdfbd0251be:12
event: channel.started
data: {"id": 12, "boot_id": "d10435418d284eea8d9f6cdfbd0251be", "type": "channel.started", "time": "2026-10-12T16:17:39.796333+00:00", "origin": "sonorium", "data": {"channel_id": "2c62267b", "name": "Kitchen", "theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4", "preset_id": "slow_night_at_the_inn", "volume": 40, "speakers": ["net:dlna:fake-10"]}}

id: d10435418d284eea8d9f6cdfbd0251be:15
event: channel.changed
data: {"id": 15, "boot_id": "d10435418d284eea8d9f6cdfbd0251be", "type": "channel.changed", "time": "2026-10-12T16:17:41.983708+00:00", "origin": "sonorium", "data": {"channel_id": "2c62267b", "name": "Kitchen", "changes": {"speakers_muted": true}}}

: keep-alive

Browsers' EventSource can't send headers: use ?token= there.

The event stream also accepts an older token made for it alone: POST /api/events/token (Admin) answers {"token": "…"} once and replaces the previous one; GET /api/events/token says whether one is set; DELETE /api/events/token revokes it. A key with Read events does the same job.

Event catalogue

GET /api/events/catalogue (ev.read) returns this list with descriptions. Events of a channel all have channel_id and name.

Event When Data
sonorium.started Sonorium finished starting; plugins are on version
channel.created A channel was added channel_id, name, theme_id
channel.deleted A channel was deleted channel_id, name
channel.started A channel started playing on its speakers theme_id, preset_id, volume, speakers
channel.stopped A channel stopped reason: stopped, paused, idle (no speaker pulled audio for 90 s), ended (a playlist without repeat finished), schedule (Kid mode's hours ended)
channel.changed A channel's settings changed changes: only what changed, any of theme_id, preset_id, sequence_id, playlist_id, volume, speakers_muted, announcements, name, speakers
channel.listening The first listener came, or the last one left listening
channel.output_added / channel.output_removed A cast device was reported playing the channel, or stopped output_id, kind, output_name, address; removed: reason (removed, expired, replaced, stopped, paused, idle, deleted)
sequence.step A sequence moved to a step (also step 1 when it starts) theme_id, sequence_id, step, steps, preset_id
playlist.entry A playlist moved to an entry playlist_id, position, entries, theme_id
sound.started / sound.ended A sound or announcement started or ended over a channel sound_id, label, origin; ended: reason (finished, replaced, cancelled, stopped, error), capped, played (seconds)
ramp.started / ramp.finished / ramp.cancelled A channel's volume ramp ramp_id, from, to, seconds, curve, then; level; cancelled: reason
zone.switched Zones were switched on or off on
zone.changed A channel's owner, Kid mode or hours changed changes: any of owner, kid_mode, schedule
zone.speaker_taken A channel took a speaker another channel was playing on speaker_id, from_channel_id, by: home or admin
zone.home_changed A speaker has a new home channel speaker_id, channel_id, name, reason: deleted, removed, admin
zone.schedule_ended Kid mode's hours ended channel_id, name
link.created / link.changed / link.removed An admin linked channels, changed or removed a link link_id, name; created: channels, theme_id, preset_id, playlist_id; changed: changes
link.left / link.rejoined A channel left a link or joined it again link_id, name, channel_id; rejoined: by
speaker.added A speaker appeared speaker_id, name, type
speaker.removed A speaker is no longer known speaker_id, name
speaker.unavailable / speaker.available A speaker went offline, or came back speaker_id, name
theme.created A theme was created, uploaded or imported theme_id, folder
theme.changed A theme's files changed (saves within a second are one event) theme_id, folder
theme.deleted A theme was deleted theme_id
plugin.enabled / plugin.disabled A plugin was switched on or off plugin_id, name
schedule.fired A plugin's scheduled job ran plugin_id, job_id, name, data, scheduled_for, missed
trigger.received A webhook trigger was called plugin_id, trigger, payload, method
command.received A plugin handed over a remote's code plugin_id, map_id, code, actions, learned
error Something failed that nobody was waiting for where, message, plugin_id
plugin.<id>.<name> A plugin's own event the plugin's

Not sent as events: playlist and speaker group edits, floor and area changes, settings changes. Read those again when you need them.


Channels

A channel is what you create on the Channels page: a theme, a preset or sequence, speakers and a volume. In the API a channel is a session (/api/sessions, session_id, 8 hex characters such as 2c62267b). Separately, /api/channels lists numbered stream slots (/stream/channel1…); a channel borrows a slot while it plays. Use /api/sessions.

The channel object

{
  "id": "2c62267b",
  "name": "Kitchen",
  "name_source": "custom",
  "theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4",
  "preset_id": "slow_night_at_the_inn",
  "speaker_group_id": null,
  "adhoc_selection": {"include_floors": [], "include_areas": [], "include_speakers": ["net:dlna:fake-10"],
                      "exclude_areas": [], "exclude_speakers": []},
  "volume": 35,
  "is_playing": false,
  "speakers": ["net:dlna:fake-10"],
  "speaker_summary": "Kitchen speaker",
  "channel_id": null,
  "sequence_id": null,
  "preset_choice": "slow_night_at_the_inn",
  "playlist_id": null,
  "sequence": null,
  "playlist": null,
  "speakers_muted": false,
  "announcements": true,
  "listening": false,
  "outputs": [],
  "recent": [],
  "created_at": "2026-10-12T16:17:39.746583",
  "last_played_at": null
}
Field Type Meaning
id string The channel's id
name string Its name
name_source custom or auto Typed by someone, or made from its speakers
theme_id, preset_id string or null The theme and preset
speaker_group_id string or null A saved speaker group, or null when adhoc_selection is used
adhoc_selection object or null Floors, areas and speakers to include, areas and speakers to leave out
volume integer 0-100 The volume
is_playing boolean Playing on its speakers
speakers string[] The speaker ids the selection gives now
speaker_summary string The speakers in words, e.g. "Kitchen speaker", "No speakers"
channel_id integer or null The stream slot while it has one (/stream/channel{N})
sequence_id, playlist_id string or null The sequence or playlist it runs
preset_choice string or null What the preset box shows: seq:<sequence_id> while a sequence is set, else preset_id
sequence, playlist object or null Where the sequence or playlist is (below)
speakers_muted boolean The speakers get silence; a browser or phone listening still hears the channel
announcements boolean Announcements play on this channel
listening boolean Someone listens in a browser or the app
outputs object[] Cast devices the app reported playing it
recent [{at, text}] The latest changes, newest first
created_at, last_played_at string or null Local times without an offset

Routes

Method Path Ability What it does
GET /api/sessions ch.view Every channel (a key with channels: only those)
POST /api/sessions ch.add Create a channel (stopped). 201 + the channel
GET /api/sessions/{id} ch.view One channel
PUT /api/sessions/{id} ch.change (+ ch.speakers to change speakers) Change theme, preset, speakers, name, volume, Announcements; only the fields sent change
DELETE /api/sessions/{id} ch.add Delete it. 204, no body
POST /api/sessions/{id}/play ch.play Play on its speakers
POST /api/sessions/{id}/pause ch.play Pause
POST /api/sessions/{id}/stop ch.play Stop (fades out)
POST /api/sessions/stop-all ch.play Stop every channel
POST /api/sessions/{id}/volume ch.change Set the volume
PUT /api/sessions/{id}/speakers-muted ch.change or sp.mute Mute or unmute the speakers
PUT /api/sessions/{id}/announcements ch.change Announcements on or off
GET /api/sessions/{id}/playback ch.view Sequence and playlist status
POST /api/sessions/{id}/sequence pl.run Run a sequence of its theme
DELETE /api/sessions/{id}/sequence pl.run Stop the sequence (the current preset stays)
PUT /api/sessions/{id}/playlist pl.run Play a playlist
DELETE /api/sessions/{id}/playlist pl.run Stop the playlist (the current theme stays)
POST /api/sessions/{id}/next pl.run Next playlist entry, or the sequence's next step
POST /api/sessions/{id}/listen ch.play Start the mix for a listener and get the stream path
DELETE /api/sessions/{id}/listen ch.play The listener is gone
GET /api/sessions/{id}/outputs ch.view Cast devices reported playing it
POST /api/sessions/{id}/outputs ch.play Report a cast device playing it
PUT /api/sessions/{id}/outputs/{output_id} ch.play Heartbeat for a reported device
DELETE /api/sessions/{id}/outputs/{output_id} ch.play The device stopped
GET /api/now-playing ch.view What each playing channel's mixer does now
GET /api/channels, /api/channels/{n} ch.view The stream slots
POST /api/channels/{n}/play, /stop ch.play Older slot routes: they bypass channels. Use /api/sessions.
POST /api/channels/{n}/volume ch.change Older slot route; it changes nothing

Creating and changing a channel

POST /api/sessions and PUT /api/sessions/{id} take the same fields; all are optional.

Field Type Meaning
theme_id string A theme's id. A playing channel crossfades to it. With no preset_id, the theme's default preset.
preset_id string A preset of the theme (the channel glides to it over 3 s and a running sequence stops), "" for none, or seq:<sequence_id> to run that sequence
speaker_group_id string Play on a saved speaker group
adhoc_selection object {include_floors, include_areas, include_speakers, exclude_areas, exclude_speakers}, each a list of ids
custom_name string The name
volume integer 0-100 The volume (new channels: Settings' default volume)
announcements boolean Announcements play on it (default true)

Changing speakers on a playing channel starts and stops speakers as needed. Only speakers switched on in Settings › Speakers can play.

Errors: 404 unknown channel, theme or sequence; 400 a bad value; 409 a sequence without presets; 403 Not allowed to change speakers (no ch.speakers); 422 volume outside 0-100.

B=http://192.0.2.20:8008/api
# create
curl -X POST $B/sessions -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d '{"theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4", "custom_name": "Kitchen", "volume": 35,
          "adhoc_selection": {"include_speakers": ["media_player.kitchen"]}}'
# preset, theme, speaker group
curl -X PUT $B/sessions/2c62267b -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"preset_id": "slow_night_at_the_inn"}'
curl -X PUT $B/sessions/2c62267b -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"theme_id": "b851228a-94ca-47b7-aa55-576ceb791adb"}'
curl -X PUT $B/sessions/2c62267b -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"speaker_group_id": "downstairs"}'

Play, stop and volume

Route Body Answer
POST /api/sessions/{id}/play none {"status": "playing", "channel_id": 1, "sequence_id": null, "playlist_id": null}
POST /api/sessions/{id}/pause none {"status": "paused"}
POST /api/sessions/{id}/stop none {"status": "stopped"}
POST /api/sessions/stop-all none {"stopped": 2} (how many)
POST /api/sessions/{id}/volume {"volume": 40} (0-100) {"volume": 40}
PUT /api/sessions/{id}/speakers-muted {"muted": true} {"speakers_muted": true}
PUT /api/sessions/{id}/announcements {"on": false} {"announcements": false}

Play, pause and stop answer at once; fades and speaker commands follow. Play errors: 400 No theme selected, Theme not found, No speakers selected; 404 unknown channel. Watch channel.started and channel.stopped to know when the speakers follow.

curl -X POST $B/sessions/2c62267b/play   -H "Authorization: Bearer $KEY"
curl -X POST $B/sessions/2c62267b/volume -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"volume": 40}'
curl -X POST $B/sessions/2c62267b/stop   -H "Authorization: Bearer $KEY"

Sequence and playlist status

GET /api/sessions/{id}/playback, and the answer of the sequence, playlist and next routes:

{
  "session_id": "2c62267b",
  "sequence_id": "evening_at_the_inn",
  "preset_choice": "seq:evening_at_the_inn",
  "playlist_id": null,
  "sequence": {"id": "evening_at_the_inn", "name": "Evening at the inn", "running": true, "single_pass": false,
               "step": 1, "steps": 2, "at_end": "reverse", "current_preset_id": "slow_night_at_the_inn",
               "current_preset_name": "Slow night at the inn",
               "next_change_at": "2026-10-12T16:27:49.085183+00:00", "seconds_until_change": 607},
  "playlist": null
}

A running playlist:

"playlist": {"id": "e76d8472", "name": "Evening", "running": true, "entry_index": 0, "position": 0, "entries": 2,
             "theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4", "theme_name": "Tavern",
             "next_change_at": "2026-10-12T16:57:23.387626+00:00", "seconds_until_change": 2381}
Route Body Errors
POST /api/sessions/{id}/sequence {"sequence_id": "evening_at_the_inn"} 400 no id, 404 unknown, 409 a sequence without presets
DELETE /api/sessions/{id}/sequence none 404
PUT /api/sessions/{id}/playlist {"playlist_id": "e76d8472"} 400, 404, 409
DELETE /api/sessions/{id}/playlist none 404
POST /api/sessions/{id}/next none 409 The channel is not playing, 409 No playlist or sequence is running

next answers the status plus "skipped": "playlist" or "sequence".

Listening and cast devices

POST /api/sessions/{id}/listen with {"hold_seconds": 0-300} (default 15) starts the channel's mix without its speakers and answers {"channel_id": 1, "stream_path": "/stream/channel1", "hold_seconds": 60}. Play http://<host>:8008<stream_path>?local=1 while you listen, then DELETE /api/sessions/{id}/listen ({"status": "ok"}). hold_seconds is how long the mix waits for the listener to reconnect after its connection drops. Errors: 400 No theme selected, Theme not found, hold_seconds must be between 0 and 300; 409 No free channel (every slot is busy).

POST /api/sessions/{id}/outputs reports a cast device the caller sent the stream to, so the channel's card shows it and the mix keeps running: body {"kind": "cast", "name": "Office display", "address": "192.0.2.10", "receiver_id": "…"} (kind: lowercase, up to 20 characters, default cast; name up to 60, default Cast device; address and receiver_id up to 100). The 201 answer has output_id, stream_url, heartbeat_seconds (60) and expire_seconds (180). Send PUT …/outputs/{output_id} every 60 s; a device not heard from for 180 s is dropped (404 Output not found on the next heartbeat). At most 10 per channel. The full flow is in docs/APP_API.md §9.

Now playing

GET /api/now-playing is the Status page's view of each playing channel: every track's state and the seconds until its next change.

{"at": 1791821861.802, "channels": [{
  "id": "2c62267b", "name": "Kitchen", "theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4",
  "theme_name": "Tavern", "preset_name": "Slow night at the inn", "sequence": null,
  "speakers_playing": true, "speakers": "Kitchen speaker", "speakers_muted": false, "listening": false,
  "buffer_seconds": 4.0,
  "sections": [
    {"title": "Background", "rows": [
      {"type": "track", "key": "fireplace_663", "name": "fireplace_663", "state": "on", "mode": "Background",
       "now": "Playing", "seconds": null, "level": 1.0, "band": null}]},
    {"title": "Intermittent", "rows": [
      {"type": "track", "key": "Wooden_Door_Slamming_723", "name": "Wooden_Door_Slamming_723", "state": "wait",
       "mode": "Intermittent", "now": "Next in {t}", "seconds": 205.3, "level": null, "band": null}]}]}]}

at: the server's time (seconds since 1970). state: on, fade, wait, rest, off. now holds {t} where the countdown seconds goes. level and band are 0-1 (band: an Ebb & Flow track's range). A group row has tracks. buffer_seconds: the speakers' estimated buffer. sequence: {name, step, steps} or null.


Announcements, sounds and volume ramps

Sounds play over a channel's mix: the channel's sound ducks (gets quieter), the sound plays, the channel comes back, like a radio presenter talking over the music. Every speaker, browser and cast device of the channel hears it.

POST /api/announce

an.send (Make announcements). A chime, spoken text, or both, on one or more channels. Announcements override zones and quiet hours, and a stopped channel's speakers are started for the announcement and stopped again (the channel doesn't show as playing).

Field Type Default Meaning
channels string, string[] or "all" required Channel names (not case-sensitive) or ids
text string Spoken with text to speech (also accepted as message)
url string An http(s) URL of a chime played first (also accepted as chime)
volume number 0-200 100 100 is the file's level
duck true, false, number, or {db, attack, release} true true: Settings › Advanced's Duck amount, attack and release. A number: that many dB, the rest from Advanced
provider string the first one The text to speech provider (homeassistant comes with the Announcements plugin)
language string Passed to the provider
start_stopped boolean true Start stopped channels' speakers for it

text or url is required. Channels with Announcements off are left out and listed in skipped.

curl -X POST $B/announce -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d '{"channels": ["Kitchen"], "url": "http://192.0.2.20:8008/api/test-tone.mp3", "volume": 80}'
{"played": ["2c62267b"], "skipped": [],
 "channels": {"2c62267b": {"state": "playing", "message": ""}},
 "chime": {"sound_id": "s_6fc8bc18349e", "length": 3.0, "capped": false,
           "channels": {"2c62267b": {"state": "playing", "message": ""}}},
 "speech": null, "notice": null}

state per channel: playing, queued, starting (its speakers are being started) or skipped (with message: "The channel is stopped", "Speakers muted", "The channel has no speakers", "Announcements are off", a zone's reason). notice: why the text wasn't spoken when only the chime played.

Errors: 400 no text or url, a bad url, a bad duck or volume; 404 No channel to announce on or an unknown channel name; 403 a channel outside the key's channels; 503 No text to speech is available… (text with no provider and no url); 502 the provider failed.

Text to speech comes from Home Assistant, through the built-in Announcements plugin, when Home Assistant is linked (Settings › Connection, or the Home Assistant app). A plugin can add other providers.

Sounds

Method Path Ability What it does
GET /api/sounds ch.view Sounds playing or waiting on each channel
POST /api/sounds an.send Play a sound over channels
DELETE /api/sounds/{sound_id} an.send Cancel it: {"cancelled": true}; 404 when it isn't playing or waiting

POST /api/sounds fields:

Field Type Default Meaning
channels string, string[] or "all" required Names or ids, 1-50 channels
url string required An http(s) URL of an audio file
volume number 0-200 100
duck as for announce true
policy now, queue, replace queue now: at once, over anything playing. queue: after what plays, higher priority first. replace: cuts sounds of the same or lower priority
priority integer 0 Announcements use 10
max_seconds number above 0 Settings › Advanced › Longest sound (60 s) Cut the sound sooner
start_stopped boolean false Start a stopped channel's speakers for it; false skips stopped channels
override boolean false Skip zones and quiet hours
label string, up to 80 A name for it in events and lists
announcement boolean false Channels with Announcements off skip it

Answer: {"sound_id": "s_8e436db9551d", "length": 3.0, "capped": false, "channels": {"2c62267b": {"state": "queued", "message": ""}}}. capped: the sound was cut at the longest length.

GET /api/sounds:

{"sounds": [{"sound_id": "s_8e436db9551d", "state": "queued", "priority": 0, "policy": "queue", "length": 3.0,
             "position": 0.0, "volume": 1.0, "duck": {"db": 15.0, "attack": 0.5, "release": 1.5}, "capped": false,
             "label": "Doorbell", "origin": "api", "channel_id": "2c62267b", "name": "Kitchen"}]}

Network speakers (Cast, Sonos, AirPlay, DLNA) buffer a few seconds of audio, so they play a sound that much later than it was sent.

Volume ramps

A ramp makes a channel louder or quieter gradually, smooth to the sample: an alarm that rises over 20 minutes, a sleep timer that fades out over an hour. It changes the channel's mix; the speakers' volume stays as it is.

Method Path Ability Body / answer
GET /api/sessions/{id}/ramp ch.view {"channel_id", "level", "ramp": {id, level, from, to, curve, elapsed, seconds, then} or null}
POST /api/sessions/{id}/ramp ch.change Start one (below) → {"ramp_id", "channel_id", "from", "to", "seconds", "curve", "then"}
DELETE /api/sessions/{id}/ramp?reset=false ch.change Cancel: holds the level reached; reset=true goes back to full level → {"cancelled", "ramp_id", "level", "reset"}
Field Type Default Meaning
to number 0-4 required 0 silent, 1 the mix as it is, up to 4 (+12 dB)
seconds number, 0 or more required How long
from number 0-4 the level now Where it starts
curve db, linear, ease, exp db db sounds even to the ear
then stop, pause or null null Stop or pause the channel when it ends
start boolean false Play the channel first if it is stopped
# a 30-minute sleep timer
curl -X POST $B/sessions/2c62267b/ramp -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d '{"to": 0, "seconds": 1800, "then": "stop"}'

A finished ramp holds its level until the channel stops or another ramp starts. Errors: 400 a level outside 0-4, negative seconds, an unknown curve or then; 404 unknown channel.

Audio outputs

Plugins can add audio outputs (a sound card, a Bluetooth speaker) that a channel is sent to besides its speakers.

Method Path Ability What it does
GET /api/audio-outputs ch.view {"types": [{id, title, plugin_id, available, message}]}
GET /api/audio-outputs/{type}/devices sp.view {"devices": [{id, name, …}]}
GET /api/sessions/{id}/audio-outputs ch.view {"attachments": [{attachment_id, channel_id, type, device, options, running, latency, error}]}
POST /api/sessions/{id}/audio-outputs ch.speakers {"type", "device", "options"} → 201, the attachment; the mix starts if needed
DELETE /api/sessions/{id}/audio-outputs/{attachment_id} ch.speakers {"removed": true}; 404 unknown

Themes

Method Path Ability What it does
GET /api/themes th.view Every theme (summary below)
POST /api/themes/refresh th.view Rescan the theme folders → {"status": "ok", "themes_count": 3, "message": "Refreshed 3 themes"}
GET /api/themes/{id} th.view {"id", "name", "track_count", "url", "tracks": [{"name"}]}
GET /api/themes/{id}/tracks th.view {theme_id, theme_name, short_file_threshold, tracks: [track]}
GET /api/themes/{id}/tracks/{track}/audio th.view The audio file (supports Range)
GET /api/themes/{id}/groups th.view {"groups": [...]}: each group's settings and tracks
GET /api/themes/{id}/export th.view The theme as a .zip
GET /api/categories th.view {"categories": ["Weather", "Christmas", "Fantasy"]}

Theme summary (GET /api/themes):

{"id": "df297db5-a98d-4372-8029-2c49d1e280e4", "name": "Tavern", "total_tracks": 8, "enabled_tracks": 8,
 "url": "http://192.0.2.20:8008/stream/df297db5-a98d-4372-8029-2c49d1e280e4",
 "description": "A night at a medieval inn.", "icon": "🍺", "is_favorite": false, "has_audio": true,
 "categories": ["Fantasy"], "short_file_threshold": 15.0, "problems": []}
Field Meaning
icon An emoji, or ""
url A stream of the theme with the settings saved in it, for previews. Channels play /stream/channel{N}.
has_audio false for an empty theme folder
problems Sentences about a broken metadata.json or presets.json

Track (GET /api/themes/{id}/tracks):

{"name": "fireplace_663", "presence": 1.0, "muted": false, "is_enabled": true, "duration_seconds": 16.2,
 "is_short_file": false, "volume": 1.0, "playback_mode": "continuous", "seamless_loop": false, "exclusive": false,
 "fade_in": 0.0, "fade_out": 0.0, "volume_low": 0.2, "pause_between": false, "pause_min": 30.0, "pause_max": 300.0,
 "rise": 0.0, "group": null, "intrusion_group": false}
Field Type Meaning
name string The track key (file name without extension; Group/file inside a group)
playback_mode string continuous (Background), sparse (Intermittent), presence (Ebb & Flow), unassigned (not placed: not played)
volume, presence, volume_low, rise number 0-1 Volume; how often it is heard (Intermittent) or how long (Ebb & Flow); Ebb & Flow's lower volume; Ebb & Flow's rise
muted boolean Muted
seamless_loop boolean Loops without a crossfade
exclusive boolean Plays alone among exclusive tracks
fade_in, fade_out seconds, 0-30 The file's fades
pause_between, pause_min, pause_max boolean, seconds Ebb & Flow: play once, pause, come back
group string or null Its group

The track settings, groups and theme files are explained on Track Settings and in docs/THEME_FORMAT.md.

Editing themes (Admin)

Changes to a theme apply to every channel playing it.

Method Path Ability Body → answer
PUT /api/themes/{id}/tracks/{track}/presence th.edit {"presence": 0-1} → {"status": "ok", "track", "presence"}
PUT /api/themes/{id}/tracks/{track}/volume th.edit {"volume": 0-1}
PUT /api/themes/{id}/tracks/{track}/muted th.edit {"muted": true}
PUT /api/themes/{id}/tracks/{track}/playback_mode th.edit {"playback_mode": "continuous" | "sparse" | "presence" | "unassigned"}
PUT /api/themes/{id}/tracks/{track}/seamless_loop th.edit {"seamless_loop": true}
PUT /api/themes/{id}/tracks/{track}/exclusive th.edit {"exclusive": true}
PUT /api/themes/{id}/tracks/{track}/fades th.edit {"fade_in": s, "fade_out": s} (either) → both
PUT /api/themes/{id}/tracks/{track}/ebb th.edit any of {"volume_low": 0-1, "pause_between": bool, "pause_min": s, "pause_max": s}
POST /api/themes/{id}/tracks/reset th.edit Every track back to its defaults → {"status": "ok", "theme_id"}
POST /api/themes/{id}/tracks/{track}/move th.edit {"group": "Lute"} or {"group": null} (top level) → {"track": new key}
POST /api/themes/{id}/groups th.edit {"name": "Lute"} → 201 {"name"} (a new subfolder)
PUT /api/themes/{id}/groups/{group} th.edit any of presence, volume (0-1), muted, gap_min, gap_max (s), mode (intermittent, merry_go_round, ebb_flow), crossfade (s, 1-60 by default), pause_between, rise; null resets one
POST /api/themes/{id}/groups/{group}/rename th.edit {"name"} → {"name", "tracks"}
DELETE /api/themes/{id}/groups/{group} th.edit Tracks move to the top level
GET /api/themes/{id}/links/source/{source_id} th.view Tracks of another theme that can be linked
POST /api/themes/{id}/links th.edit {"theme": source id, "tracks": [keys], "group": name or null} → 201 {"links": [...]}
POST /api/themes/{id}/links/{key}/copy-in th.edit {"group": name or null}: copy a linked file into this theme → {"track"}
DELETE /api/themes/{id}/links/{key} th.edit Remove a linked file (never the file itself) → {"removed"}
PUT /api/themes/{id}/rename th.edit {"name"} (the display name; the folder stays)
PUT /api/themes/{id}/metadata th.edit any of {"description", "icon", "short_file_threshold"}
POST /api/themes/{id}/favorite th.edit Toggles → {"theme_id", "is_favorite"}
POST /api/themes/{id}/categories th.edit {"categories": ["Weather"]} → {"theme_id", "categories"}
POST /api/categories th.edit {"name"} → {"status": "ok", "category", "categories"}; 409 exists
DELETE /api/categories/{name} th.edit → {"status": "ok", "deleted", "categories"}
POST /api/themes/create th.upload {"name", "description"?, "icon"?}: an empty theme
POST /api/themes/{id}/upload th.upload multipart file (an audio file), optional group; a new track starts Unassigned
POST /api/themes/import th.upload multipart file: a theme .zip (as exported)
DELETE /api/themes/{id} th.upload Delete the theme and its files

Presets

A preset is a saved set of a theme's track and group settings.

Method Path Ability What it does
GET /api/themes/{id}/presets th.view The theme's presets and sequences (below)
GET /api/themes/{id}/presets/{pid} th.view {"id", "name", "tracks": {key: settings}, "groups": {...}}
GET /api/themes/{id}/presets/{pid}/export th.view {"name", "tracks"} for sharing
POST /api/themes/{id}/presets th.edit {"name"}: save the theme's current settings as a preset → {"status": "ok", "preset_id", "name", "is_default"}
PUT /api/themes/{id}/presets/{pid} th.edit Overwrite it with the theme's current settings
POST /api/themes/{id}/presets/{pid}/load th.edit Apply it to the theme's own settings
PUT /api/themes/{id}/presets/{pid}/default th.edit Make it the default → {"status": "ok", "preset_id", "is_default": true}
PUT /api/themes/{id}/presets/{pid}/rename th.edit {"name"}
DELETE /api/themes/{id}/presets/{pid} th.edit → {"status": "ok", "preset_id"}; another preset becomes the default
POST /api/themes/{id}/presets/import th.edit {"preset_json": "<the export as text>", "name"?}

To play a preset on a channel, set it on the channel: PUT /api/sessions/{id} {"preset_id": "…"}.

{"theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4",
 "presets": [{"id": "slow_night_at_the_inn", "name": "Slow night at the inn", "is_default": true,
              "track_count": 8, "has_intrusion": false},
             {"id": "busy_night", "name": "Busy night", "is_default": false, "track_count": 8, "has_intrusion": false}],
 "default_has_intrusion": false,
 "sequences": [{"id": "evening_at_the_inn", "name": "Evening at the inn", "presets": ["slow_night_at_the_inn", "busy_night"],
                "at_end": "reverse", "hold_min": 600.0, "hold_max": 900.0, "ramp": 30.0, "owner": null,
                "transitions": {}, "while_steps": [], "effective": {"hold_min": 600.0, "hold_max": 900.0, "ramp": 30.0},
                "pass_min": 1230.0, "pass_max": 1830.0, "choice": "seq:evening_at_the_inn", "has_intrusion": false}]}

A channel's preset box lists presets, then sequences (by choice).


Sequences

A sequence runs presets of one theme in order, holding each for a while and gliding to the next.

Method Path Ability What it does
GET /api/themes/{id}/sequences pl.view {theme_id, sequence_defaults: {hold_min, hold_max, ramp}, sequences: [sequence]}
GET /api/themes/{id}/sequences/{sid} pl.view One sequence
POST /api/themes/{id}/sequences pl.edit Create → 201
PUT /api/themes/{id}/sequences/{sid} pl.edit Change any field; channels running it continue with the new version
DELETE /api/themes/{id}/sequences/{sid} pl.edit → {"status": "ok", "sequence_id", "stopped_on": [channel ids]}
GET, PUT /api/themes/{id}/sequence_defaults pl.view, pl.edit {theme_id, hold_min, hold_max, ramp}
GET, PUT /api/themes/{id}/sequences/{sid}/transitions/{preset} pl.view, pl.edit A step's transition settings
GET, PUT /api/themes/{id}/sequences/{sid}/while/{preset} pl.view, pl.edit Track changes while a step plays
GET /api/themes/{id}/transition_tracks pl.view Tracks a transition can use
Field Type Meaning
name string, up to 100 Required, unique in the theme (409 otherwise)
presets string[] At least one preset id of the theme
at_end reverse, restart, stop After the last step (default reverse)
hold_min, hold_max seconds, 0 to 604800 How long a step plays, picked between them; null = the theme's default (300, 900)
ramp seconds The glide to the next step; null = the default (30)
curl -X POST $B/themes/df297db5-a98d-4372-8029-2c49d1e280e4/sequences -H "Authorization: Bearer $KEY" \
     -H "Content-Type: application/json" \
     -d '{"name": "Evening at the inn", "presets": ["slow_night_at_the_inn", "busy_night"], "at_end": "reverse", "hold_min": 600, "hold_max": 900, "ramp": 30}'
{"id": "evening_at_the_inn", "name": "Evening at the inn", "presets": ["slow_night_at_the_inn", "busy_night"],
 "at_end": "reverse", "hold_min": 600.0, "hold_max": 900.0, "ramp": 30.0, "owner": null, "transitions": {},
 "while_steps": [], "effective": {"hold_min": 600.0, "hold_max": 900.0, "ramp": 30.0}, "pass_min": 1230.0, "pass_max": 1830.0}

effective is what plays once defaults are applied; pass_min and pass_max are the shortest and longest time one pass through the steps lasts, glides included. Run it with POST /api/sessions/{id}/sequence, or PUT /api/sessions/{id} {"preset_id": "seq:evening_at_the_inn"}. The transition and while-step bodies are in docs/PLAYBACK.md.


Playlists

A playlist plays themes one after another, each for a while.

Method Path Ability What it does
GET /api/playlists pl.view {"playlists": [playlist]}
GET /api/playlists/{id} pl.view One playlist; 404 Playlist not found
POST /api/playlists pl.edit Create → 201
PUT /api/playlists/{id} pl.edit Change any field; playing channels continue with the new version
DELETE /api/playlists/{id} pl.edit → {"status": "ok", "playlist_id", "stopped_on": [channel ids]}
Field Type Default Meaning
name string, up to 100 required Unique (409 otherwise)
entries object[], 1-500 required {"theme_id", "preset_id"?, "sequence_id"?}: a preset or a sequence, not both
order in_order, shuffle in_order
repeat boolean Start again after the last entry; without it the channel stops (channel.stopped, reason ended)
play_min, play_max seconds, 1 to 604800 1800, 3600 How long each entry plays, picked between them
fit_sequences boolean false An entry with a sequence plays for whole passes of the sequence
owner null null Always null
curl -X POST $B/playlists -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
     -d '{"name": "Evening", "entries": [{"theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4"}, {"theme_id": "b851228a-94ca-47b7-aa55-576ceb791adb"}], "order": "in_order", "repeat": true}'
curl -X PUT $B/sessions/2c62267b/playlist -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{"playlist_id": "e76d8472"}'
{"id": "e76d8472", "name": "Evening",
 "entries": [{"theme_id": "df297db5-a98d-4372-8029-2c49d1e280e4", "preset_id": null, "sequence_id": null},
             {"theme_id": "b851228a-94ca-47b7-aa55-576ceb791adb", "preset_id": null, "sequence_id": null}],
 "order": "in_order", "repeat": true, "play_min": 1800.0, "play_max": 3600.0, "fit_sequences": false, "owner": null}

Errors: 400 with the reason (name is required, unknown theme '…', an entry has a preset or a sequence, not both, play_min must not be more than play_max, …), 409 a duplicate name.


Speakers, speaker groups, floors and areas

Speakers

Method Path Ability What it does
GET /api/speakers sp.view Every speaker (below)
GET /api/speakers/hierarchy sp.view {"floors": [{floor, areas: [{area, speakers}]}], "unassigned_areas", "unassigned_speakers"}
POST /api/speakers/refresh sp.view Read Home Assistant's speakers again and, without Home Assistant, scan the network
GET /api/network-speakers sp.view Speakers found on the network: {"speakers", "total_speakers", "last_scan", "found"}
POST /api/network-speakers/refresh sp.view Scan the network now
POST /api/speakers/resolve sp.view A selection → {"speakers": [ids], "count"}
GET /api/speakers/{id}/settings sp.view {"speaker_id", "settings": {name, room, volume_offset, play_via}, "speaker"}
PUT /api/speakers/{id}/settings sp.set any of {"name", "room", "volume_offset": -20..20, "play_via": "ha" or a network speaker id}; null resets one
POST /api/speakers/{id}/test sp.set A 3 s chime at 20 % → {"speaker_id", "playing": true, "seconds"}; 409 busy, 502 failed
PUT /api/speakers/{id}/volume sp.mute {"volume": 0-100} → {"speaker_id", "volume"}
PUT /api/speakers/{id}/muted sp.mute {"muted": true} → {"speaker_id", "muted"}
GET /api/settings/speakers sp.view {"enabled_speakers": [ids], "hierarchy"}
PUT /api/settings/speakers sp.set {"enabled_speakers": [ids]}: exactly these are on
POST /api/settings/speakers/enable, /disable sp.set {"entity_id"}: one speaker on or off
POST /api/settings/speakers/enable-all, /disable-all sp.set All on or off
POST /api/speakers/manual/check sp.set Same body as below: what answers there, without adding it → {"found": true, "message", …} or {"found": false, "message"}
POST /api/speakers/manual sp.set {"address", "type": "auto" | "cast" | "sonos" | "dlna" | "airplay" | "linkplay" | "heos", "name"?, "room"?, "port"?} → 201; 400 when nothing answers
DELETE /api/speakers/manual/{id} sp.set Remove a speaker added by address
GET /api/debug/speakers st.view Diagnostic dump of speaker discovery
GET, PUT /api/settings/speaker-areas sp.view, sp.set Older custom areas: {"custom_areas": {name: [speaker ids]}}
POST /api/settings/speaker-areas/create sp.set {"name", "speakers"}
PUT, DELETE /api/settings/speaker-areas/{name} sp.set Change or remove one
POST /api/settings/speaker-areas/{name}/add-speaker, /remove-speaker sp.set {"entity_id"}
{"entity_id": "net:chromecast:a944ea78-85c9-670a-b8bb-295712de07b1", "name": "Office display",
 "area_id": null, "area_name": null, "floor_id": null, "floor_name": null,
 "ip_address": "192.0.2.10", "source": ["discovered"], "type": "cast", "online": true,
 "address": "192.0.2.10", "original_name": "Office display", "default_area_id": null,
 "volume_offset": 0, "merged": [], "play_via": "ha"}

entity_id is the speaker's id everywhere: media_player.… for Home Assistant's, net:<type>:<id> for one Sonorium found. PUT …/volume and …/muted act on the speaker now (a network speaker without mute is set to volume 0 and back); after that, GET /api/speakers lists volume and muted for it until Sonorium restarts. Errors: 404 Speaker not found, 422 out of range, 502 The speaker didn't take the change.

Speaker groups

Method Path Ability What it does
GET /api/groups sp.view Every group
GET /api/groups/{id} sp.view One group; 404 Group not found
GET /api/groups/{id}/resolve sp.view {"speakers", "count", "summary"}
POST /api/groups sp.set {"name", "icon"?, "include_floors"?, "include_areas"?, "include_speakers"?, "exclude_areas"?, "exclude_speakers"?} → 201
PUT /api/groups/{id} sp.set Any of those
DELETE /api/groups/{id} sp.set 204; 409 while channels use it

Group: {id, name, icon ("mdi:speaker-group" by default), include_floors, include_areas, include_speakers, exclude_areas, exclude_speakers, speakers, speaker_count, summary, created_at, updated_at}. A floor or area in a group includes speakers added there later.

Floors and areas

Method Path Ability What it does
GET /api/spaces sp.view {"editable", "floors": [{id, name, source, local_id, speakers, areas}], "unassigned_areas"}
POST /api/spaces/floors, /api/spaces/areas st.change {"name", "floor_id"?} → the new /api/spaces
PUT /api/spaces/floors/{id}, /api/spaces/areas/{id} st.change {"name"?, "floor_id"?}
DELETE /api/spaces/floors/{id}, /api/spaces/areas/{id} st.change

On the Home Assistant app, floors and areas come from Home Assistant: editable is false and changes answer 404 Floors and areas are managed in Home Assistant.


Settings, status and the server

Method Path Ability What it does
GET /api/info public The server (above)
GET /api/auth/me public Who you are and your abilities
GET /api/status ch.view {"version", "current_theme", "themes", "v2_enabled", "sessions", "speaker_groups", "channels", "active_channels", "playlist_sessions"}
GET /api/install ch.view {"install", "label", "version", "features"}
PUT /api/install/features st.change {"<feature>": bool, "restart": bool}: switch on a feature that is off by default (overridable); it applies after a restart ("restart": true restarts now)
GET /api/settings ch.view Settings (below)
PUT /api/settings st.change Any of them
GET /api/settings/playback-values ch.view Settings › Advanced's values: {"values", "defaults"}
PUT /api/settings/playback-values st.change {key: number or null} (null = default)
GET /api/stream/url ch.view {"base": "http://192.0.2.20:8008", "channels": {channel id: stream URL}}
GET /api/logs?after={seq} st.view {"entries": [{time, level, source, message, seq}]}, newer than seq
GET /api/logs/download st.view The log as a file
GET /api/alerts st.view The alert bar's alerts: {"alerts": [...]}
POST /api/alerts/{id}/dismiss st.view {"dismissed": true}
GET /api/ha-cards, /api/ha-cards/download st.view The Home Assistant dashboard cards
GET /api/connection admin.connection Settings › Connection: {"standalone", "ha_url", "ha_token_set", "ha_connected", "mqtt_host", "mqtt_port", "mqtt_username", "mqtt_password_set", "mqtt_connected", "stream_url"}
PUT /api/connection admin.connection Any of ha_url, ha_token, mqtt_host, mqtt_port, mqtt_username, mqtt_password, stream_url; an empty token or password keeps the saved one. Sonorium restarts to apply them → {"status": "ok", "restarting": true}. 404 on the Home Assistant app
POST /api/connection/test admin.connection Try the values
DELETE /api/connection/ha, /api/connection/mqtt admin.connection Forget the Home Assistant or MQTT connection
GET /api/test-tone.mp3 public The speaker test chime (3 s), useful as a test sound
GET /openapi.json ch.view The machine-readable route list (FastAPI)

Settings:

Field Type Default Meaning
default_volume integer 0-100 60 A new channel's volume
crossfade_duration number 0-10 3.0 Seconds of crossfade when the theme changes
max_groups integer 1-50 20 The most speaker groups
entity_prefix string sonorium Home Assistant entity id prefix
show_in_sidebar boolean true Show Sonorium in Home Assistant's sidebar
auto_create_quick_play boolean true
master_gain integer 0-100 60 Overall level of every mix

Advanced values (values, seconds unless noted): sparse_min_interval 180, sparse_max_interval 1800, sparse_interval_variance 0.3 (a fraction), group_gap 120, loop_crossfade 1.5, group_crossfade_min 1, group_crossfade_max 60, track_fade 6, switch_fade 3, ebb_long_rest 0.12 (a chance), ebb_turn 0.35 (a chance), short_file_threshold 15, sound_max_length 60 (0 = no limit), duck_amount 15 (dB), duck_attack 0.5, duck_release 1.5, wake_wait 20, wake_lead 1, wake_tail 1. A PUT answers 400 for an unknown key, a negative number, a chance above 1 or a minimum above its maximum. What each does: Advanced Settings.


Plugins

Method Path Ability What it does
GET /api/plugins pg.use or admin.plugins Every plugin: {id, name, version, description, author, enabled, builtin, auth_provider, settings, ui_schema, settings_schema, api_version, compatible, problem}
GET /api/plugins/{id} pg.use or admin.plugins One plugin
GET /api/plugin-framework pg.use or admin.plugins {"api_version": "1.1", "capabilities", "offered", "tts_providers"}
GET /api/extensions pg.use Pages and Connection rows of plugins that are on
GET /api/plugins/{id}/pages/{page} pg.use A plugin page
POST /api/plugins/{id}/pages/{page}/actions/{action} pg.use A page button: {"values": {…}, "row": id or null} → {"success", "message", "refresh"}
POST /api/plugins/{id}/action pg.use A settings-form button: {"action", "data"}
GET, POST /api/triggers/{plugin}/{trigger} tr.run or the trigger's token Call a webhook trigger → 202 {"accepted": true, "event_id"}
PUT /api/plugins/{id}/enable, /disable admin.plugins Switch on or off
GET, PUT /api/plugins/{id}/settings admin.plugins {"settings": {name: value}}; checked against the plugin's fields (400 otherwise)
POST /api/plugins/upload admin.plugins multipart file: a plugin .zip
GET /api/plugins/catalog admin.plugins The Catalog, with installed, installed_version, update_available
POST /api/plugins/install-from-catalog admin.plugins {"plugin_id"}
POST /api/plugins/reload admin.plugins Load every plugin again
DELETE /api/plugins/{id} admin.plugins Uninstall
GET /api/plugins/{id}/packages admin.plugins The Python packages it needs and their state
GET /api/plugins/{id}/triggers admin.plugins Its triggers, their URLs, which tokens are set
POST, DELETE /api/plugins/{id}/triggers/token admin.keys Make ({} the plugin's, {"trigger": id} one trigger's) or revoke (?trigger=) a trigger token
GET /api/schedules admin.plugins Every plugin's jobs and the scheduler's time zone
PUT /api/schedules/settings admin.plugins {"timezone": "Europe/London"} ("" = the system's)
GET, POST /api/plugins/{id}/schedules admin.plugins Its jobs; add or replace one
DELETE /api/plugins/{id}/schedules/{job} admin.plugins Remove a job
PUT /api/plugins/{id}/schedules/{job}/enabled admin.plugins {"enabled": bool}
GET, PUT /api/plugins/{id}/connections/{conn} admin.plugins A plugin's Connection row: fields, values (no secrets), status; save
POST /api/plugins/{id}/connections/{conn}/test admin.plugins Try it
GET /api/command-actions admin.plugins What a command map entry can do
GET /api/plugins/{id}/command-maps admin.plugins Its command maps
PUT /api/plugins/{id}/command-maps/{map} admin.plugins {"entries": [...]}
POST /api/plugins/{id}/command-maps/{map}/learn, /test admin.plugins Wait for the next code; run a code ({"code"})

Everything a plugin can do, and how these routes fit together, is on Plugin Development.


These routes come from the built-in Integration (keys) and Users (accounts, zones) plugins; while a plugin is off, its routes answer 404.

Keys (Integration plugin, Admin)

Method Path What it does
GET /api/keys {"keys": [key], "areas": [the boxes], "expiry": ["7d", "30d", "90d", "1y", "never"]}
POST /api/keys {"name": up to 60, "abilities": [...], "channels": [ids] or null, "expires": "90d"} → the key with "key": "sk_…" (shown once)
PUT /api/keys/{id} Any of name, abilities, channels, expires ("keep" keeps it)
DELETE /api/keys/{id} Revoke → {"revoked": true}
{"key": "sk_EXAMPLE0000000000000000000000000000000000", "id": "t_f41a4d7cd4daac05", "name": "Kitchen only",
 "abilities": ["ch.view", "ch.play", "an.send"], "chips": ["Channels: view, play and stop", "Make announcements"],
 "channels": ["2c62267b"], "expires": "2026-11-11T16:17:42.405703+00:00",
 "created": "2026-10-12T16:17:42.405741+00:00", "created_by": "admin", "last_used": null}

Unknown abilities are dropped; 400 Give the key a name., Unknown expiry; 404 No such key.

Users and sign-in (Users plugin)

Method Path Permission What it does
POST /api/auth/login public {"username", "password"}: sign in a browser (cookie)
POST /api/auth/logout public Sign out
POST /api/auth/setup public The first admin account, while there is none
POST /api/auth/code-login public {"username", "code"}: sign in with a sign-in code from the app
POST /api/auth/new-password public (signed in) Choose a new password
PUT /api/auth/require-login admin.users {"on": bool}
GET, POST /api/users admin.users List users; add one {"username", "password", "role": "admin" | "user"}
PUT /api/users/{id} admin.users {"role"} and/or {"can_leave_link": bool}
POST /api/users/{id}/password admin.users Set a password (409 for a Home Assistant user)
POST /api/users/{id}/sign-out admin.users Sign out their browsers
DELETE /api/users/{id}/sessions/{session} admin.users Sign out one browser
DELETE /api/users/{id} admin.users Remove a user

Linked phones

Method Path Permission What it does
POST /api/link self A Link App code and QR code
POST /api/pair public, HTTPS only The app trades the code for a phone token
POST /api/pair/login public, HTTPS only The app signs in with a username and password
GET /api/devices self Your phones (an Admin: everyone's)
DELETE /api/devices/{id} self Unlink a phone
GET, PUT, DELETE /api/devices/self device A phone's record, new name, unlinking itself
POST /api/devices/recovery-code device A sign-in code for a forgotten password

These are for the Sonorium app; docs/APP_API.md §4-5 has every field.

Method Path Ability What it does
GET /api/zones ch.view Zones on or off, owners, Kid mode, speakers' homes, links
PUT /api/zones/switch st.change {"on": bool}
PUT /api/zones/{channel_id} st.change {"owner", "kid_mode", "schedule": {"start": "HH:MM", "end": "HH:MM"}}
PUT /api/zones/homes/{speaker_id} st.change {"channel_id"}: the speaker's home channel
POST /api/links st.change {"name", "channels", "theme_id", "preset_id"} or {"name", "channels", "playlist_id"}
PUT, DELETE /api/links/{link_id} st.change Change or unlink
POST /api/links/{link_id}/leave, /rejoin ch.play {"channel_id"}

While Zones are on, a refused change to a channel answers 403 (not yours, Kid mode, outside the hours) or 409 (a speaker in use, a linked channel), with the reason in detail. See Sign-in and Users.


Audio streams

Path What it plays
/stream/channel{N} A channel's stream slot (N = 1 to max_channels, 6 by default). N is the channel's channel_id, or comes from stream_path of listen.
/stream/channel{N}?local=1 The same, as a listener: keeps the mix running without speakers, and is heard while the speakers are muted
/stream/{theme_id} A theme with the settings saved in it (previews)

Examples: Home Assistant, Node-RED, scripts

Home Assistant: a rest_command

Sonorium's MQTT entities already cover play, stop, volume, theme and preset (MQTT Entities). The API adds what entities don't: announcements with options, ramps, playlists. In configuration.yaml (the key is in secrets.yaml as sonorium_key: "Bearer sk_…"):

rest_command:
  sonorium_announce:
    url: "http://192.0.2.20:8008/api/announce"
    method: post
    headers:
      Authorization: !secret sonorium_key
    content_type: "application/json"
    payload: '{"channels": {{ channels | tojson }}, "text": {{ text | tojson }}, "volume": 90}'
  sonorium_sleep_timer:
    url: "http://192.0.2.20:8008/api/sessions/{{ channel_id }}/ramp"
    method: post
    headers:
      Authorization: !secret sonorium_key
    content_type: "application/json"
    payload: '{"to": 0, "seconds": {{ minutes * 60 }}, "then": "stop"}'

In an automation:

action: rest_command.sonorium_announce
data:
  channels: ["Kitchen", "Living Room"]
  text: "The laundry is done"

The key needs Make announcements, and Channels: Change theme, preset and volume for the ramp.

Node-RED

  1. Drag an http request node into the flow and double-click it.
  2. Set Method to POST.
  3. Set URL to http://192.0.2.20:8008/api/sessions/2c62267b/play.
  4. Check Use authentication.
  5. Set Type to bearer authentication.
  6. Paste the key into Token.
  7. Click Done.
  8. Connect an inject node to it.
  9. Click Deploy.

An http request node ends when the answer ends, so it can't follow the event stream. Install a Server-Sent Events client node from Manage palette and point it at http://192.0.2.20:8008/api/events/stream?types=channel.*&token=sk_EXAMPLE….

Python

import json
import urllib.request

BASE = "http://192.0.2.20:8008/api"
KEY = "sk_EXAMPLE0000000000000000000000000000000000"


def call(method, path, body=None):
    data = None if body is None else json.dumps(body).encode()
    request = urllib.request.Request(BASE + path, data=data, method=method,
                                     headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
    with urllib.request.urlopen(request) as answer:
        text = answer.read()
        return json.loads(text) if text else None


kitchen = next(c for c in call("GET", "/sessions") if c["name"] == "Kitchen")
call("POST", f"/sessions/{kitchen['id']}/volume", {"volume": 30})
call("POST", f"/sessions/{kitchen['id']}/play")

PowerShell

$key = "sk_EXAMPLE0000000000000000000000000000000000"
$headers = @{ Authorization = "Bearer $key" }
$channels = Invoke-RestMethod -Uri "http://192.0.2.20:8008/api/sessions" -Headers $headers
Invoke-RestMethod -Method Post -Uri "http://192.0.2.20:8008/api/sessions/$($channels[0].id)/stop" -Headers $headers