Integration API
An API for external tools that want device/sync/server status, and to
trigger a library rescan, without holding a browser session or posing as a
sync device — Home Assistant, Grafana, a shell script, an uptime monitor.
Shipped in 2.8.0; its field types settled in 2.8.1 —
2.8.1 is the practical minimum, since 2.8.0 emits is_own/is_pinned
as 0/1 rather than real booleans. A second endpoint, server-wide
metrics, arrived in 2.9.0. A rescan-triggering action arrived
the same release, which went through two shapes before merging:
an initial version split read and write into two separate credential
types, revised before merge to the single, admin-only-minted token
described below. 2.9.0 is required
for both the server-metrics endpoint and the action route; the original
devices endpoint alone still only needs 2.8.1. A fourth endpoint, mirror
health, arrived later — device sync already had a monitoring surface
here; the three newer sinks (plus the pre-existing filesystem one)
didn't.
See Architecture & Sync Protocol for how this token fits alongside the browser session and per-device tokens for the design rationale behind treating Home Assistant as a monitoring/automation surface rather than a sync target.
What it is — and isn't
- Every endpoint here, one credential, admin-minted. The read routes
(
/api/integrations/devices,/api/integrations/server,/api/integrations/mirrors,/api/integrations/playlist-sourcesand/api/integrations/playlists) and the two actions (/api/integrations/actions/scanand/api/integrations/actions/playlist-sync) are all authenticated by the same integration token. Only an admin can mint one (POST /api/integration-tokensrequires an admin session) — that's the property that keeps a rescan trigger safe now, not a second credential type. A household realistically has one admin (whoever installed the server); everyone else uses a phone or watch, not their own integrations./api/integrations/devicesis per-viewer (it returns exactly what the token's owner would see in the web UI's device list — in practice, since the owner is always an admin, that's every device);/api/integrations/serverand/api/integrations/mirrorsare both instance-wide — the same numbers every logged-in user's own dashboard or admin panel already shows them, so neither is scoped per caller at all. - A token cannot create or revoke other tokens — that's a
session-only, admin-only route. And it can only ever do the things
above: it is wired into its own authenticator
(
_authenticated_integration_token), never into the one every session-authenticated route (including every mutating one) uses, so it cannot reach anything outside these routes regardless of what's added elsewhere later. - The token grants nothing an ordinary admin session couldn't already do — starting a library scan has never required anything beyond being logged in, admin or not, in the web UI. What minting requires is admin, though: that's what replaced the earlier read-only/action split. Tokens are separately revocable per name: turning off one automation's token never touches another's.
- Admin status is checked on every use, not just at minting.
If the account that minted a token stops being an admin, its tokens
stop authenticating immediately, the same
401as an unknown token — a demoted user's integration shouldn't be able to tell "revoked" from "never existed." Deleting the account revokes its tokens too (a plain foreign-key cascade, unaffected by this). - It is not a general-purpose public API. The device API
(
/api/device/*) is for the sync clients and is a different, unrelated contract — a different credential, different rate-limit bucket, and not meant for external tools at all. The same is true of the App API (/api/app/*), which the phone calls with its device token to browse, stage and send — a contract for the sync clients' own screens, not for external tools either.
Creating and revoking a token
Profile → Integrations in the web UI, visible to admins only: create it, name it, and the value is shown once — copy it before closing the dialog, the same as a device token. Revoke from the same place; revocation is immediate.
Worth stating up front rather than in a footnote: this credential is
long-lived and typically ends up pasted into another system's
configuration (a secrets.yaml, a Grafana data source, a cron job). Knowing
where the off switch is matters more than it does for a password you type
and forget.
Devices
GET /api/integrations/devices
Authorization: Bearer <token>
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
https://trobar.example.com/api/integrations/devices
Errors
| Status | Meaning |
|---|---|
401 |
Missing Authorization header, or the token is wrong / revoked. |
404 |
This server predates 2.8.0 — the endpoint doesn't exist yet. |
429 |
Rate-limited — see below. |
Every /api/* error responds {"error": "..."} with the matching status
code, not an HTML error page — safe to jq -r .error in a script.
The response
A JSON array, one object per device the token's owner can see (their own, plus anything pinned to them; an admin's token sees every device, matching what an admin session already sees):
| Field | Type | Notes |
|---|---|---|
id |
integer | Stable identifier — use this, not name, as your own key. Names are user-editable. |
name |
string | |
device_type |
string | Currently one of phone, tablet, watch, dap, sdcard, folder — don't treat this as a closed set. |
owner_user_id |
integer | |
owner_username |
string | |
is_own |
boolean | true if this is the token owner's own device. |
is_pinned |
boolean | true if visible to the token owner via delegation without being theirs. A household's token can see other members' devices this way — decide deliberately whether to distinguish them in whatever you build. |
max_size_bytes |
integer or null |
The configured storage cap; null means no limit. Can exceed reported_free_bytes — pick your "percent full" denominator deliberately and expect it can read over 100%. |
reported_free_bytes |
integer or null |
See null semantics below. |
reported_total_bytes |
integer or null |
Same caveat as reported_free_bytes. |
free_bytes_reported_at |
string or null |
Timestamp of the last storage report; null alongside the two fields above. |
reported_foreign_bytes |
integer or null |
Bytes of music already in the device's sync folder that Trobar did not put there. It counts against max_size_bytes, which is a limit on the folder rather than on Trobar's own share of it — so a device can be at its limit while Trobar holds very little of it. null ≠ 0 — see null semantics. |
foreign_bytes_reported_at |
string or null |
Timestamp of the last such report — the only way to tell a current figure from a stale one. null alongside the field above. |
created_at |
string | |
last_seen_at |
string or null |
|
source_of_truth |
string | "server" (the default) or "device". "device" is the web UI's Keep device content: the server still adds what is selected but never marks a track on the device for removal. Nothing else changes. |
transcode_format |
string or null |
null means sync originals, not "unset". Otherwise one of mp3_320, mp3_256, mp3_192, mp3_128. |
artist_images |
string or null |
null means off. Otherwise "small" or "full". |
unknown_track_count |
integer or null |
null ≠ 0 — see below. |
autofit |
object | Shape depends on enabled — see below. |
sync_status |
object | {"pending_count": integer, "last_synced_at": string or null}. |
Timestamps are the server's own format — SQLite's datetime('now'), i.e.
YYYY-MM-DD HH:MM:SS, always UTC but without a T separator or an
explicit offset. Attach UTC yourself rather than assuming your parser
infers it.
autofit's two shapes
{"enabled": false, "percent": 100}
{
"enabled": true,
"percent": 80,
"period": "6month",
"albums": 42,
"tracks": 511,
"bytes": 2147483648
}
period is the listening-history window the albums are ranked over:
6month unless the caller sends one of 7day, 1month, 3month,
6month, 12month or overall. The web UI never sends one, and a
refresh without period sets it back to 6month. percent, period, albums,
tracks, and bytes only mean something when enabled is true — while disabled, the object is just the two
fields above, not the full shape with zeros filled in.
Null semantics
Three genuinely different things, easy to collapse into one "missing data" bucket by mistake:
sync_status.last_synced_at: null— enrolled but never synced. Not an error; treat it as "never," a real answer.reported_free_bytes/reported_total_bytes/free_bytes_reported_atallnull, permanently, on some devices — the Garmin watch client only ever calls/api/device/changes,/api/device/file, and/api/device/ack; it never calls/api/device/storage, so a watch reports no storage data at all, ever. Treat this as unavailable by design, not as data that hasn't arrived yet — a consumer that waits for it will wait forever.reported_foreign_bytes/foreign_bytes_reported_atbothnull— the device has never told the server what else is in its music folder. Two causes with the same shape: an app older than the feature, and any client that does not report it at all (the Garmin watch, again by design).nullis not zero: zero would mean the folder was measured and holds nothing but Trobar's own files, and the server's own budget keeps the two apart rather than assuming an unmeasured folder is empty. A consumer that substitutes zero shows a full folder as empty.unknown_track_count: null— the count only exists once a device has completed the re-enrollment manifest handshake, in which it uploads a list of what's actually on it. The desktop and Android clients both do this, but only on the first sync after (re-)enrolling into a folder that already holds a library; the Garmin client never does, and a device enrolled into an empty folder never has cause to.nullhere means "never uploaded one," not zero unknown tracks.
On a Garmin watch, two of these three are permanent — not pending
A consumer that reads null as "hasn't arrived yet" and keeps polling
will wait forever: the watch never calls /api/device/storage, and never
uploads a manifest. Only last_synced_at fills itself in, once the device
syncs.
Server metrics
GET /api/integrations/server
Authorization: Bearer <token>
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
https://trobar.example.com/api/integrations/server
Same errors and rate limiting as Devices above — one shared credential type and one shared failure bucket, not a separate contract per route.
Not /api/library/stats, and deliberately narrower than it. That
endpoint reads every non-deleted track's full row to aggregate codec and
decade breakdowns in Python — fine for a dashboard opened occasionally, not
for something polled every few minutes indefinitely. This endpoint answers
only what a cheap, indexable aggregate can: no by_codec, no by_decade —
open the web UI's Home dashboard for those.
A single JSON object, one snapshot of the whole instance — not scoped to the calling token's owner:
| Field | Type | Notes |
|---|---|---|
version |
string | Same value as GET /api/about. |
track_count |
integer | Non-deleted tracks, library-wide. |
total_bytes |
integer | Sum of size over the same set. 0 on an empty library, never null. |
scan_running |
boolean | A library scan is queued or actively running right now. |
last_scan_at |
string or null |
When the most recent scan (success or failure) finished. null before the first scan ever runs, and while one is in progress — see below. |
No online field: a response arriving at all is that signal. An
unreachable server can't answer this request to say it's unreachable, so a
field that could only ever read true would be noise, not information —
infer reachability from whether the poll itself succeeded, the same way
does.
last_scan_at follows the same null semantics pattern
as the devices endpoint's own timestamps: null here means "no completed
scan to report on right now," not "unknown" — while scan_running is
true, last_scan_at reports null even if a previous scan finished
earlier, so a client can't mistake an in-progress scan for a stale
"finished" timestamp.
Mirror health
GET /api/integrations/mirrors
Authorization: Bearer <token>
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
https://trobar.example.com/api/integrations/mirrors
Same errors and rate limiting as Devices above.
Filled a gap the other three routes didn't cover: playlist mirroring
(filesystem, Subsonic, Jellyfin, Emby, Music Assistant, Plex) is unattended
background work with a documented, per-sink failure state
(unset_target/unreachable/no_target_matches/write_failed, Music
Assistant's and Plex's readback_mismatch as well, or
filesystem's own unset_folder/not_writable/bad_filename/
marker_unsafe), but that state was previously only visible in the
session-authenticated web UI (GET /api/provider/playlists, and
Administration → Mirrors for admins). Device sync already had this
kind of surface — this is the mirrors equivalent.
A single JSON object, instance-wide like Server metrics above, not scoped to the calling token's owner:
| Field | Type | Notes |
|---|---|---|
mirrors_failing |
integer | Total currently-failing playlist × sink pairs, across every sink. Exact — never affected by failing's cap below. The simplest possible alert: > 0. |
by_sink |
object | One entry per sink — filesystem, subsonic, jellyfin, emby, music_assistant, plex — each {"enabled": integer, "failing": integer, "short": integer}. Counts playlist × sink pairs, not playlists: a playlist mirrored to both Subsonic and Jellyfin counts once in each. by_sink.*.failing summed across all sinks always equals mirrors_failing, and by_sink.*.short equals mirrors_short. |
failing |
array | One entry per currently-failing pair — a worklist, not an inventory, capped at 50 entries (see failing_truncated). Each entry: {"playlist_id": integer, "title": string, "sink": string, "error_code": string, "last_written_at": string or null}. |
failing_truncated |
boolean | true if more pairs are failing than failing has room for — mirrors_failing/by_sink are still exact in that case, only the array is short. A single dead mirror target can fail every playlist pointed at it, so this can happen on a real install, not just synthetically. |
mirrors_short |
integer | Enabled playlist × sink pairs whose last write left tracks off the target: tracks the target doesn't have, or ones it refused after the write. Exact, like mirrors_failing. Never counts the filesystem mirror, which has no target. |
short |
array | One entry per short pair, capped at 50 (see short_truncated): {"playlist_id": integer, "title": string, "sink": string, "on_target": integer, "total": integer, "lacked": integer, "refused": integer, "run_at": string}. total is the tracks Trobar sent (the ones it matched locally, a repeated track counted once on Music Assistant and Plex), on_target how many the copy holds, lacked how many the target doesn't have, refused how many it dropped after accepting the write (Music Assistant and Plex only; always 0 elsewhere). |
short_truncated |
boolean | true if more pairs are short than short has room for. |
A short pair is not a failing one: a partial copy is the normal state of
a target with a different library. mirrors_short exists so a monitor can
show coverage or watch it change, not as a second alarm. The tracks
themselves are listed per pair in Administration → Playlist mirrors.
error_code is the raw machine-readable code, the same one
GET /api/admin/mirrors exposes alongside its own rendered message — this
endpoint deliberately omits the message (these codes are
language-independent so a client renders its own strings rather than
displaying server-side English). unset_target counts as failing like
any other code: a mirror target cleared out from under playlists still
enabled against it is exactly the silent-drift case this endpoint exists
to surface.
Playlist source health
GET /api/integrations/playlist-sources
Authorization: Bearer <token>
Same errors and rate limiting as Devices above.
Every place a playlist sync reads from is a source: the library source itself, a member's own listing on it (a Roon profile, a Jellyfin or Emby user), a member's linked Tidal or Spotify account, and each public link. Each finished sync records whether each source answered; a cancelled one records nothing. A source alerts once it has failed 3 runs in a row and has been failing for at least 12 hours, so one bad night alerts nobody, and its first success clears it. Admins see the alerting ones in the web UI's header, and every source in Administration → Configuration → Library → Source health. Instance-wide, like Mirror health:
| Field | Type | Notes |
|---|---|---|
sources_total |
integer | Every source the last sync tried. |
sources_failing |
integer | Sources whose latest run failed, alerting or not: for an earlier warning than sources_alerting. |
sources_alerting |
integer | Sources the rule above alerts for. Exact, never affected by alerting's cap. The alert: > 0. |
alerting |
array | One entry per alerting source, capped at 50 (see alerting_truncated): {"kind": string, "provider": string, "account": string or null, "failing_since": string, "runs": integer, "reason": string}. |
alerting_truncated |
boolean | true if more sources alert than alerting has room for. |
kind is library_source, member_listing, linked_account or
public_link. account is the member's Trobar username, or a public link's
playlist title, and null for the library source. failing_since is ISO 8601 in
UTC with its zone, YYYY-MM-DDTHH:MM:SSZ. reason is the provider's own short code or error
text from the last failed run, cut to one line of at most 200 characters.
Device playlists
GET /api/integrations/playlists
Authorization: Bearer <token>
Which playlists each device has, and how complete each one is, plus the server's totals. Devices are the ones the token's owner may see, as on Devices.
| Field | Type | Meaning |
|---|---|---|
playlists_total |
integer | Every playlist on the server. |
playlists_with_gaps |
integer | Playlists with entries not found in the library that nobody has excluded or accepted, as the Playlists tab's Gaps view counts them. |
devices |
array | One entry per device: {"device_id": integer, "name": string, "playlists_count": integer, "playlists": array, "playlists_truncated": boolean}. |
Each of a device's playlists is {"playlist_id": integer, "title":
string or null, "private": boolean, "tracks": integer, "matched": integer,
"available": integer or null, "has_gaps": boolean}. available is the share of the playlist's
entries found in the library, as a whole percentage; null for a
playlist with no entries. A device lists at most 100 playlists;
playlists_count is always the full number, and playlists_truncated
says when the list is cut.
A private playlist (one a member owns and hasn't shared) is listed and
counted like any other, with "private": true and "title": null. What
an integration shows can reach people the web UI wouldn't show the title
to, so it never leaves the server.
Actions
POST /api/integrations/actions/scan
Authorization: Bearer <token>
Content-Type: application/json
{"force": false}
curl -X POST -H "Authorization: Bearer <YOUR_TOKEN>" \
https://trobar.example.com/api/integrations/actions/scan
The same token as Devices, Server metrics, and Mirror health — see What it is — and isn't above for why one admin-minted credential covers every route here rather than a separate write-capable token.
Triggers the same background library scan as the web UI's own "Scan
library" button (POST /api/library/scan), and answers with the identical
shape:
| Status | Body | Meaning |
|---|---|---|
202 |
{"status": "started", "job_id": <int>} |
Scan started in the background — poll scan_running/last_scan_at on /api/integrations/server for completion. |
409 |
{"error": "..."} |
A scan is already running — this call did not start a second one. |
force (optional, default false) is read from the JSON body: false
skips files whose size and modification time haven't changed since the
last scan, true
re-reads everything. Same meaning as the web UI's own force-rescan option.
Playlist sync
POST /api/integrations/actions/playlist-sync
Authorization: Bearer <token>
Starts the same playlist sync as the web UI's Sync playlists button: from the active library provider, every linked Tidal and Spotify account and every playlist link. It answers like the scan:
| Status | Body | Meaning |
|---|---|---|
202 |
{"status": "started", "job_id": <int>} |
Sync started in the background. Playlists change as it commits them; read Device playlists afterwards. |
409 |
{"error": "..."} |
A playlist sync is already running, scheduled or started from the web UI; this call did not start a second one. |
The server also syncs playlists on its own schedule, so this is for "now", not for keeping them current.
Rate limiting
30 failed authentication attempts per 5 minutes, per IP, in its own
bucket, shared by every route above — separate from the login rate
limiter, so a misconfigured integration retrying a stale token can
neither trip nor be tripped by a brute-force login lockout. Only failures
count against it; a stream of successful polls never approaches the
limit. Every successful request also updates the token's
last_used_at, shown next to it in Profile → Integrations — so a
working poller shows up there, and a lockout after 30 failures tells you
the token needs replacing, not that this endpoint is flaky.
Recipes
Sync itself is slow-moving — the Android client syncs on a 6-hour period — so polling every 30 seconds fetches an identical payload hundreds of times between real changes. A few minutes is plenty; trobar-ha itself polls every 5 minutes.
Home Assistant, native
The turnkey path: trobar-ha, covered on its own page — see Home Assistant Integration.
Home Assistant, no custom component
Point HA's built-in rest sensor
at this endpoint directly — no installation, at the cost of one sensor
definition per value you want and no device registry grouping. This is
also Phase 0 of the integration's design: the zero-install fallback
for anyone who'd rather not add a custom repository.
# configuration.yaml
sensor:
- platform: rest
name: "Trobar living room phone — pending tracks"
resource: https://trobar.example.com/api/integrations/devices
method: GET
headers:
Authorization: !secret trobar_api_token # store as "Bearer <token>"
value_template: >-
{{ (value_json | selectattr('id', 'equalto', 1) | first).sync_status.pending_count }}
scan_interval: 300 # 5 minutes -- see the note above on poll frequency
Store the full header value in secrets.yaml, not just the token:
# secrets.yaml
trobar_api_token: "Bearer <YOUR_TOKEN>"
Mirror health is a flat object rather than an array, so
its value_template skips the selectattr filter entirely:
# configuration.yaml
sensor:
- platform: rest
name: "Trobar — mirrors failing"
resource: https://trobar.example.com/api/integrations/mirrors
method: GET
headers:
Authorization: !secret trobar_api_token
value_template: "{{ value_json.mirrors_failing }}"
json_attributes:
- by_sink
- failing
scan_interval: 300
json_attributes carries by_sink and failing onto the sensor's own
attributes — HA's sensor state is length-limited, but attributes hold
structured data fine, so an automation can alert on the bare count while
a dashboard card still shows which sink and playlist.
Grafana
Via the Infinity datasource (JSON, no plugin-specific server-side code needed):
- Add an Infinity data source, Auth type Bearer Token, paste the raw
token (no
Bearerprefix — Infinity adds it). - New panel → query type JSON, URL
https://trobar.example.com/api/integrations/devices, Parser Backend, root selector empty (the response is already the array). - Columns:
name(string),reported_free_bytes(number),reported_total_bytes(number) — a bar gauge overreported_free_bytesgrouped bynamegives free space per device in one panel. - Set the panel/dashboard refresh interval to a few minutes, matching the note above — Infinity re-polls on every refresh, same as any other data source.
Shell / uptime monitors
#!/bin/sh
# Alert if any device has tracks still pending sync.
resp=$(curl -sf -H "Authorization: Bearer $TROBAR_TOKEN" \
https://trobar.example.com/api/integrations/devices) || exit 1
stuck=$(echo "$resp" | jq '[.[] | select(.sync_status.pending_count > 0)] | length')
if [ "$stuck" -gt 0 ]; then
echo "ALERT: $stuck device(s) with tracks still pending"
exit 1
fi
Run this every few minutes via cron, or point an uptime monitor with a
JSON-body-assertion feature (Uptime Kuma's "Json Query" monitor type, for
one) directly at the same jq expression instead of a separate script.
mirrors_failing is built for exactly this shape of check — no jq
filter needed, just the field:
#!/bin/sh
# Alert if any playlist mirror is currently failing.
resp=$(curl -sf -H "Authorization: Bearer $TROBAR_TOKEN" \
https://trobar.example.com/api/integrations/mirrors) || exit 1
failing=$(echo "$resp" | jq '.mirrors_failing')
if [ "$failing" -gt 0 ]; then
echo "ALERT: $failing mirror(s) failing — $(echo "$resp" | jq -c '.by_sink')"
exit 1
fi
See also
- Devices & Storage — the same fields, from the web UI's perspective.
- Home Assistant Integration — the native client for this API.
- Architecture & Sync Protocol — how this token fits alongside the others.