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:
- Home Assistant automations and scripts (
rest_command), next to the MQTT entities Sonorium already gives Home Assistant. - Node-RED flows (the
http requestnode, and the event stream for triggers). - Scripts (curl, Python, PowerShell) and other programs: dashboards, bots, wall panels, ESP32 buttons.
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
- Home Assistant app: use the Home Assistant machine's address. 8008 and 8808 are the app's Network mappings (8008 is port 8080 inside the container); change them on the app's Network tab. Home Assistant's sidebar link is for people signed in to Home Assistant; programs use the direct ports.
- Docker: host networking serves both ports on the host.
SONORIUM_HTTPS_PORTchanges 8808. - Docker and Windows/Linux installs announce
sonorium.localon the network (mDNS), sohttp://sonorium.local:8008works without knowing the address. When another device already has that name, Sonorium issonorium-2.local(up tosonorium-9.local); the log says which name it got. The Home Assistant app doesn't announce a name.
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:
- Save the certificate:
openssl s_client -connect 192.0.2.20:8808 </dev/null 2>/dev/null | openssl x509 -outform PEM > sonorium.pem - Print its fingerprint:
openssl x509 -in sonorium.pem -outform DER | sha256sum - Compare it with
fingerprintfromGET /api/info. They must be the same. - 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):
- Open Settings in the left menu.
- Click Keys.
- Click New key (top right).
- Type a Name, such as
Kitchen tablet. - Pick Expires: In 7 days, In 30 days, In 90 days (the default), In 1 year or Never.
- Check what the key may do (the boxes are listed under Abilities).
- Under Channels, leave All channels, or click Only these… and check the channels.
- Click Make key.
- 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
- Request bodies are JSON objects, sent with
Content-Type: application/json. Uploads aremultipart/form-data. - Times are ISO 8601. Most are UTC (
+00:00); a channel'screated_atandlast_played_atare local time without an offset. - Unknown fields in answers can appear in later versions: ignore what you don't know.
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
- Each event has
id,boot_id,type,time(UTC),originanddata.originissonorium(the web UI, the API, Home Assistant, timers) orplugin:<id>. - In events,
channel_idis the channel's id (theidof/api/sessions). - A
: keep-aliveline comes every 15 s. Treat 45 s of silence as a lost connection. retry: 5000: reconnect after 5 s. Send the lastid:you received asLast-Event-ID(or?last_event_id=) and Sonorium replays the events since then that it still holds (the last 200), then goes on live.- The id is
<boot_id>:<n>.nstarts again at 1 when Sonorium restarts. An id from before a restart replays every event of the new run Sonorium still holds; whenboot_idchanges, read the state again (GET /api/sessions). - A reader that falls more than 500 events behind loses the oldest.
- Errors: 401 no or wrong key, 400 a bad pattern, 503 too many streams open.
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.
Keys, users, linked phones, zones and links
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.
Zones and links (Users plugin)
| 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) |
- No key: speakers can't sign in. Anyone on the network can listen to a channel that plays; changing anything needs the API.
- MP3, 44.1 kHz, mono, 128 kbps, chunked, no length and no seeking. Each listener joins at the live point.
- A slot that isn't playing answers 200 with no audio.
POST /api/sessions/{id}/listenstarts it first. - Use port 8008: Cast devices and most speakers refuse a self-signed HTTPS address.
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
- Drag an http request node into the flow and double-click it.
- Set Method to
POST. - Set URL to
http://192.0.2.20:8008/api/sessions/2c62267b/play. - Check Use authentication.
- Set Type to bearer authentication.
- Paste the key into Token.
- Click Done.
- Connect an inject node to it.
- 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