Documentation for controlling and preserving Bose SoundTouch devices
Reference document derived from mitmproxy captures of the Bose SoundTouch Android app
(bose-pairing-20260502-155542, bose-pairing-20260502-165549).
Why this matters: With Bose cloud services shutting down on 2026-05-06, the original app may stop working for pairing and playback control. This document captures the exact WebSocket message sequences needed to replicate those flows independently.
All interactions use the SoundTouch WebSocket API on the speaker’s local IP, port 8090
(the same port as the REST API). Connect with the Gabbo sub-protocol:
GET ws://192.168.x.y:8090/
Upgrade: websocket
Sec-WebSocket-Protocol: Gabbo
Upon connection the server immediately sends an identification banner:
<SoundTouchSdkInfo serverVersion="4" serverBuild="trunk r46330 v4 epdbuild hepdswbld04" />
All subsequent messages (except selectLastWiFiSource, see below) use this envelope:
Client → Server request:
<msg>
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
<request requestID="{n}">
<info type="new"/> <!-- or type="update" -->
<!-- optional: <sourceItem source="TUNEIN"/> -->
</request>
</header>
<body>
<!-- payload, may be empty -->
</body>
</msg>
Server → Client response:
<?xml version="1.0" encoding="UTF-8" ?>
<msg>
<header deviceID="{device_id}" url="{endpoint}" method="{GET|POST}">
<request requestID="{n}" msgType="RESPONSE">
<info type="new"/>
</request>
</header>
<body>
<!-- response payload -->
</body>
</msg>
Server → Client push (unsolicited):
<updates deviceID="{device_id}">
<nowPlayingUpdated>...</nowPlayingUpdated>
</updates>
requestID is a monotonically increasing integer per connection (client-side sequence).
{device_id} is the speaker’s MAC address with colons removed (e.g. 08DF1F0BA325).
<!-- C→S: fetch device info -->
<msg><header deviceID="{device_id}" url="info" method="GET">
<request requestID="1"><info type="new"/></request>
</header></msg>
<!-- S→C: response -->
<info deviceID="{device_id}">
<name>SoundTouch 10</name>
<type>SoundTouch 10</type>
<margeAccountUUID>9569497</margeAccountUUID> <!-- empty = unpaired -->
<margeURL>https://streaming.bose.com</margeURL>
...
</info>
margeAccountUUID → device is unpaired, proceed to Phase 2margeAccountUUID → already paired with that account IDThe pairing flow uses a setup state machine on the device. States must be sent in order.
<!-- 1. Start setup -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="21"></request>
</header><body><setupState state="SETUP_START"/></body></msg>
<!-- 2. Enter identify mode — device flashes/beeps; 300 000 ms timeout -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="22"></request>
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_ENTER" timeout="300000"/></body></msg>
<!-- Server pushes: -->
<updates deviceID="{device_id}">
<soundTouchConfigurationUpdated>
<soundTouchConfigurationStatus status="SOUNDTOUCH_CONFIGURING"/>
</soundTouchConfigurationUpdated>
</updates>
<!-- 3. Set language (3 = German; adjust as needed) -->
<msg><header deviceID="{device_id}" url="language" method="POST">
<request requestID="23"></request>
</header><body><sysLanguage>3</sysLanguage></body></msg>
<!-- 4. Enter setup (user has confirmed identification) -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="24"></request>
</header><body><setupState state="SETUP_ENTER"/></body></msg>
<!-- 5. Leave identify mode -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="25"></request>
</header><body><setupState state="SETUP_IDENTIFY_DEVICE_LEAVE"/></body></msg>
<!-- 6. Set device name -->
<msg><header deviceID="{device_id}" url="name" method="POST">
<request requestID="26"></request>
</header><body><name>My SoundTouch 10</name></body></msg>
<!-- C→S: pair device with account -->
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
<request requestID="27"></request>
</header><body>
<PairDeviceWithAccount>
<accountId>{accountId}</accountId>
<userAuthToken>Bearer {token}</userAuthToken>
</PairDeviceWithAccount>
</body></msg>
<!-- S→C: device info response with margeAccountUUID now set -->
<info deviceID="{device_id}">
...
<margeAccountUUID>{accountId}</margeAccountUUID>
...
</info>
The server also pushes several sourcesUpdated events after successful pairing.
{accountId} — the numeric Bose account ID (e.g. 9569497), obtainable from
GET /streaming/account/login on soundtouch-service.
{token} — a Bearer token issued by Bose authentication (or soundtouch-service).
The full token from the captures:
Bearer NtJDRbNtY3hDhm5K8FC2JprRhRQNH3QdZjG6aR4ASwYQg4rvZMY6dPLc3Bm6zvWNciWzCpMWZ/dbITRQoVdClOdssgDO+Nlh4ZJWp2w3tZiGzB8Flho0c+ipXnT/0Yg5
(session-specific; obtain a fresh one from the service’s account login flow)
<!-- Leave setup state machine -->
<msg><header deviceID="{device_id}" url="setup" method="POST">
<request requestID="28"></request>
</header><body><setupState state="SETUP_LEAVE"/></body></msg>
<!-- Trigger device to sync customer support info to Marge cloud -->
<msg><header deviceID="{device_id}" url="pushCustomerSupportInfoToMarge" method="GET">
<request requestID="29"></request>
</header></msg>
<!-- S→C: -->
<status>/pushCustomerSupportInfoToMarge</status>
<!-- C→S: remove device from account -->
<msg><header deviceID="{device_id}" url="setMargeAccount" method="POST">
<request requestID="24">
<info mainNode="removeDevice" type="new"/>
<sourceItem source="SETTINGS" sourceAccount="{device_id}"/>
</request>
</header><body><UnPairDeviceWithAccount/></body></msg>
<!-- S→C: response with device info showing empty margeAccountUUID -->
<!-- Server also pushes: <updates><infoUpdated/></updates> -->
When the app connects to an already-paired device it sends these in rapid parallel sequence:
info (GET) — device metadata, check pairing
sources (GET) — available input sources
presets (GET) — saved presets 1–6
swUpdateQuery (POST) — check if update is in progress
capabilities (GET) — hardware capabilities, network config
bassCapabilities (GET) — bass range and defaults
now_playing (GET) — current playback state
volume (GET) — current volume
getZone (GET) — multi-room zone membership
clockDisplay (POST) — set clock timezone/format
Then a second wave:
swUpdateCheck (POST) — check for new firmware
systemtimeout (GET) — power-saving timeout
rebroadcastlatencymode (GET) — zone latency mode
getGroup (GET) — stereo-pair group
language (GET, sourceItem source="settings") — UI language
bass (GET) — current bass level
serviceAvailability (GET, sourceItem source="add_service" or "settings")
webserver/pingRequest (GET) — keepalive
pushCustomerSupportInfoToMarge (GET) — telemetry
netStats (GET, sourceItem source="settings") — network statistics
introspect (POST, sourceItem source="AIRPLAY") — AirPlay2 capabilities
clockDisplay example with timezone:
<clockDisplay>
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
</clockDisplay>
serviceAvailability response lists availability of all service types (PANDORA, AIRPLAY,
AMAZON, DEEZER, SPOTIFY, TUNEIN, SIRIUSXM_EVEREST, BLUETOOTH, etc.) with isAvailable
and optional reason attributes.
playbackRequest (preferred — bypasses source checks)<msg><header deviceID="{device_id}" url="playbackRequest" method="POST">
<request requestID="{n}"><info type="new"/></request>
</header><body>
<playbackRequest source="TUNEIN" sourceAccount="">
<container type="stationurl"
location="/v1/playback/station/s25260"
isPresetable="true"
source="TUNEIN"
sourceAccount="">
<itemName>1LIVE</itemName>
</container>
</playbackRequest>
</body></msg>
<!-- S→C response: -->
<playbackResponse source="TUNEIN" sourceAccount=""/>
<!-- S→C pushes: nowPlayingUpdated, recentsUpdated -->
For a TuneIn podcast episode, use type="tracklisturl" and
location="/v1/playback/episodes/{id}?encoded_name={base64}".
select (triggers preset/recents UI highlight)<msg><header deviceID="{device_id}" url="select" method="POST">
<request requestID="{n}"><info type="new"/></request>
</header><body>
<ContentItem source="TUNEIN"
type="stationurl"
location="/v1/playback/station/s25260"
sourceAccount="TUNEIN"
isPresetable="true">
<itemName>1LIVE</itemName>
</ContentItem>
</body></msg>
Note: select with a TUNEIN item that the device can’t resolve directly may return
error value="1005" name="UNKNOWN_SOURCE_ERROR". Use playbackRequest instead for
reliable playback.
A plain-text (non-XML) client message:
selectLastWiFiSource
Server responds with plain text:
<?xml version="1.0" encoding="UTF-8" ?><status>/selectLastWiFiSource</status>
<!-- press -->
<msg><header deviceID="{device_id}" url="key" method="POST">
<request requestID="{n}"><info mainNode="keyPress" type="new"/><sourceItem source="TUNEIN"/></request>
</header><body><key state="press" sender="Gabbo">{KEY}</key></body></msg>
<!-- release (required for POWER — not for STOP/PAUSE) -->
<msg><header deviceID="{device_id}" url="key" method="POST">
<request requestID="{n}"><info mainNode="keyRelease" type="new"/><sourceItem source="TUNEIN"/></request>
</header><body><key state="release" sender="Gabbo">{KEY}</key></body></msg>
Key names observed: POWER, STOP, PAUSE, ADD_FAVORITE
sender="Gabbo" is the app identifier string used by all Bose mobile apps.
<!-- Set volume (0–100) -->
<msg><header deviceID="{device_id}" url="volume" method="POST">
<request requestID="{n}"><info mainNode="volume" type="new"/><sourceItem source="TUNEIN"/></request>
</header><body><volume>30</volume></body></msg>
<!-- S→C push: -->
<updates deviceID="{device_id}">
<volumeUpdated>
<volume><targetvolume>30</targetvolume><actualvolume>30</actualvolume><muteenabled>false</muteenabled></volume>
</volumeUpdated>
</updates>
<!-- Get -->
<msg><header deviceID="{device_id}" url="bass" method="GET">
<request requestID="{n}"><info type="new"/></request>
</header></msg>
<!-- Set (range: bassMin to bassMax from bassCapabilities, typically -9 to 0) -->
<msg><header deviceID="{device_id}" url="bass" method="POST">
<request requestID="{n}"><info mainNode="bassSet" type="new"/><sourceItem source="SETTINGS"/></request>
</header><body><bass>-2</bass></body></msg>
<!-- S→C push: <updates><bassUpdated/></updates> -->
<!-- Open recents menu -->
<msg><header deviceID="{device_id}" url="navigate" method="POST">
<request requestID="{n}"><info mainNode="navigateMenu" type="new"/><sourceItem source="RECENTS"/></request>
</header><body><navigate menu="recents"/></body></msg>
<!-- S→C response: -->
<navigateResponse menu="recents">
<totalItems>4</totalItems>
<items>
<item type="stationurl" source="TUNEIN" location="/v1/playback/station/s25260"
sourceAccount="TUNEIN" isPresetable="true" id="0">
<itemName>1LIVE</itemName>
</item>
...
</items>
</navigateResponse>
Use type="update" on <info> for subsequent refresh calls on the same menu.
<!-- Read -->
<msg><header deviceID="{device_id}" url="systemtimeout" method="GET">
<request requestID="{n}"><info type="new"/></request>
</header></msg>
<!-- Write: disable auto power-off -->
<msg><header deviceID="{device_id}" url="systemtimeout" method="POST">
<request requestID="{n}"><info mainNode="systemtimeout" type="new"/><sourceItem source="SETTINGS"/></request>
</header><body><systemtimeout><powersaving_enabled>false</powersaving_enabled></systemtimeout></body></msg>
<msg><header deviceID="{device_id}" url="clockDisplay" method="POST">
<request requestID="{n}"><info mainNode="clockDisplayBypass" type="new"/></request>
</header><body>
<clockDisplay>
<clockConfig timezoneInfo="Europe/Berlin" timeFormat="TIME_FORMAT_12HOUR_ID"/>
</clockDisplay>
</body></msg>
The app sends a ping roughly every 30 seconds:
<!-- C→S -->
<msg><header deviceID="{device_id}" url="webserver/pingRequest" method="GET">
<request requestID="{n}"><info type="new"/></request>
</header></msg>
<!-- S→C -->
<pingRequest pong="true"/>
The server wraps push events in <updates deviceID="{device_id}">:
| Event element | Trigger |
|---|---|
nowPlayingUpdated |
Source/track changed, playback state changed |
nowSelectionUpdated |
Preset slot highlighted (UI selection changed) |
recentsUpdated |
Recents list changed |
presetsUpdated |
Preset saved or modified |
volumeUpdated |
Volume changed (any source) |
bassUpdated |
Bass level changed |
connectionStateUpdated |
Wi-Fi signal strength changed (EXCELLENT_SIGNAL, GOOD_SIGNAL, MARGINAL_SIGNAL, POOR_SIGNAL) |
soundTouchConfigurationUpdated |
Setup state changed (e.g. SOUNDTOUCH_CONFIGURING) |
infoUpdated |
Device info changed (e.g. after un-pairing) |
sourcesUpdated |
Available sources list changed |
Separate push (not inside <updates>):
<userActivityUpdate deviceID="{device_id}"/>
Sent after any physical or app-initiated user action.
Used by the app to notify the device of data that has changed on the service side
(e.g. after syncing presets from cloud). Header uses propagate="false":
<msg>
<header deviceID="{device_id}" url="notification" method="POST" propagate="false">
<request requestID="{n}"><info mainNode="presetsUpdated" type="new"/></request>
</header>
<body>
<updates deviceID="{device_id}"><presetsUpdated/></updates>
</body>
</msg>
<!-- S→C response: -->
<status>/notification</status>
To pair a freshly factory-reset speaker to a Bose account (soundtouch-service must be running and authenticated):
1. Connect WebSocket to ws://{speakerIP}:8090/
2. Receive: <SoundTouchSdkInfo .../>
3. GET info → confirm margeAccountUUID is empty
4. POST setup SETUP_START
5. POST setup SETUP_IDENTIFY_DEVICE_ENTER (timeout=300000)
(user physically presses button on speaker to confirm identity)
6. POST language <sysLanguage>3</sysLanguage>
7. POST setup SETUP_ENTER
8. POST setup SETUP_IDENTIFY_DEVICE_LEAVE
9. POST name <name>{desired name}</name>
10. POST setMargeAccount <PairDeviceWithAccount>
<accountId>{accountId}</accountId>
<userAuthToken>Bearer {token}</userAuthToken>
</PairDeviceWithAccount>
→ device responds with info, margeAccountUUID is now set
11. POST setup SETUP_LEAVE
12. GET pushCustomerSupportInfoToMarge (telemetry, safe to skip)
bose-pairing-20260502-155542 — Session 1: initial pairing of SoundTouch 10 to account 9569497bose-pairing-20260502-165549 — Session 2: re-pairing and full operation (TuneIn, Spotify, presets)scripts/android/mitm/{session}/mirror/{n}-websocket/*.txtscripts/android/mitm/{session}/mirror/{n}-*.http