How the own.audio server API is versioned, and what every client may rely on. This is the contract between the server (both editions) and the nine clients. It is binding from the first tagged release of this repository; before that it is the target the implementation plan works toward.
| Open-source server (this repo) | Hosted service (own.audio) | |
|---|---|---|
| Code | audio2 crate + default binary + web console |
A private binary crate that depends on this crate and adds billing, payments and operations |
| API | /api/v1/..., /rest/... (OpenSubsonic), /health |
The same, plus /api/v1/family/billing*, /api/v1/billing/* |
| How a client tells them apart | GET /api/v1/server → edition: "foss" |
edition: "hosted" |
A client never branches on edition. It branches on features (§3). The
edition field exists for diagnostics and bug reports only.
Three numbers exist. Do not confuse them.
- API major version — the path prefix,
/api/v1. It changes only for an incompatible redesign. A new major is served side by side with the old one for at least 12 months, and the old one keeps receiving security fixes. Nothing on the roadmap needs/api/v2. - Contract revision — an integer in
GET /api/v1/server(api.revision), incremented every time the v1 contract gains something: a new endpoint, a new field, a new enum value, a newfeatureskey. It never goes down and is the one number a client may compare against ("statuses by playlist need revision ≥ 7"). It is also the version ofdocs/api/openapi.json. - Server release — semver (
1.4.2), what the Docker image is tagged with and whatserver.versionreports. Clients never compare it; it is for humans and changelogs.
The OpenSubsonic surface at /rest keeps its own versioning rules
(subsonic/envelope.rs); this policy does not change them.
Unauthenticated, cheap, cacheable for an hour. The only endpoint a client may call before it knows anything about the server.
{
"id": "6f1c2a0e-3b7d-4c55-9a0e-2f8e1d7c4b19",
"name": "own.audio",
"edition": "foss",
"version": "1.0.0",
"api": { "version": 1, "revision": 12 },
"addresses": [
{ "url": "https://music.example.com", "scope": "public" },
{ "url": "http://192.168.1.20:8080", "scope": "lan" },
{ "url": "http://100.101.102.103:8080", "scope": "vpn" }
],
"features": {
"registration_open": false,
"auth": { "local": true, "google": false, "apple": false, "microsoft": false },
"uploads": { "presigned": true, "multipart_max_bytes": null },
"narration": false,
"translation": false,
"music_identify": true,
"podcast_discovery": true,
"podcast_search": true,
"file_sync": true,
"library_folders": false,
"subsonic": true,
"mail": false,
"one_family": true,
"billing": false,
"payments": false
},
"deprecations": []
}Rules:
- Every optional capability has a key here. A key is
trueonly when the feature is fully configured on this server, not merely compiled in. A server without a TTS key reportsnarration: false, even though the routes exist. - Unknown keys are ignored by clients; missing keys mean
false. That is what lets a new client talk to an old server. - A 404 from this endpoint means a server older than revision 1. Clients
treat that as the "pre-discovery baseline": local auth, presigned uploads,
everything else
false, and fall back to probingGET /auth/providers. deprecationslists endpoints or fields scheduled for removal, each with asunsetdate (§5). Normally empty.podcast_search(revision 5):POST /podcasts/searchworks. True withpodcast_discovery, and also without it when the server searches Apple's public directory instead of its own catalogue; then categories, browse and similar shows stay off.summaries(revision 20): the hosted edition summarizes an episode and answers questions about it from its transcript (/api/v1/podcast-ai/…, paid from the family's credit). Alwaysfalsein the open-source edition, likenarrationandtranslation.uploaded_podcasts(revision 18):POST /api/v2/podcastscreates a podcast whose episodes its owner uploads, andPOST /api/v2/podcasts/{id}/episodesadds one from uploads (POST /api/v2/uploads, kindsepisode,episode_transcript,podcast_cover,episode_image). Aguidmakes both safe to retry. Such a show hassource_type: "upload"and afeed_urlofupload:<id>that is never fetched; refresh does nothing, auto-store and store-all answer409 uploaded_podcast. An uploaded transcript is served throughtranscript_urllike a publisher's.track_chapters(revision 22): a music track carries the chapter marks of its file, a concert's songs:chapter_counton every v2 track, the marks onGET /api/v2/tracks/{id}(chapters) andGET /api/v1/music/tracks/{id}/chapters; the track's owner replaces them withPUT /api/v2/tracks/{id}/chapters, after which the file's marks are no longer read. A concert is an album whose release group haskind: "live": list them withGET /api/v2/albums?kind=liveorGET /api/v1/music/albums?kind=live(release_kindon each), set it withrelease_kindonPOST /api/v2/tracksorPATCH /api/v2/release-groups/{id}.book_source_choice(revision 25): a family admin chooses where "identify this audiobook" looks —openlibrary(the default),appleorgoogle— withPUT /family/settings/book-source, and may give the family's own Google Books key there (stored encrypted, never sent back).GETtells any member which source is in use.false(the hosted edition): Google Books with the server's key, as before. Search candidates carrysource; theirvolume_idis opaque (ol:…,apple:…, or Google's bare id) and goes back toapplyunchanged, which asks the source the id came from.one_family(revision 4): the install has one family. Every new account joins it, and removing a member or leaving answers409; clients hide "leave family" and offer block or delete instead.false(or missing): each account can have a family of its own.id(revision 6) is this server's identity, a UUID made once at install and never changed. A client that knows a server by several addresses uses it to tell that they reach the same server — and must not trust an address that answers with another id.addresses(revision 6) lists every address the server answers on:SERVER__BASE_URLfirst, thenSERVER__ADDRESSES(comma-separated), each once, with ascopederived from the host —lan(private, link-local,.local),vpn(100.64.0.0/10,*.ts.net) orpublic. A client learns them through whichever address it signed in on, tries them all after each network change and keeps the fastest that answers with the sameid. It should send credentials over plainhttponly tolanaddresses. May be empty.auth.password_reset(revision 7):POST /auth/password/forgotmails a single-use link (30 minutes) when the email has an account with a password, andPOST /auth/password/resetsets the password behind it and signs every session out. True when the server has mail and a console address (SERVER__APP_BASE_URL); clients show "forgot password?" only then.forgotanswers200whatever the email.auth.email_verification(revision 7): the server mails a confirmation link to accounts whose address nothing vouched for, andme.email_verified(also on every sign-in response) is false until the link is used. Clients show a banner with "resend" (POST /auth/email/resend) while it is false and treat403withcode: email_unverified(inviting people) as "confirm your address first". False: every account is born verified.auth.totp(revision 7): two-factor sign-in with an authenticator app is available (/auth/totp/*). A client must handle202from the sign-in calls —{ mfa_required: true, mfa_token, expires_in_secs, methods }— by asking for the six-digit code (or a recovery code) and posting it with the token toPOST /auth/totp/verify, which answers the usual sign-in body. Accounts without it on never see the202.demo(revision 2, optional) is present only on a public demo server:{ "email": "…", "password": "…" }, the shared account a visitor may sign in with. Clients may show it on their sign-in screen and offer to fill it in. It is set bySERVER__DEMO__EMAILandSERVER__DEMO__PASSWORD. The account is read-only (since 1.0.0-beta.4): a write answers403{"error": "the demo account is read-only"}, a Subsonic write error 50. Listening still works — playback sessions, progress, the play queue, scrobbles — and so do searches and smart-playlist previews.SERVER__DEMO__READ_ONLY=falselifts it, for a script that fills the demo.
The web console in this repo and every native client hide or disable UI for a
feature whose key is false. There is no second source of truth (no
hard-coded "the hosted server has billing").
Within /api/v1, a change is allowed only if a client built against any
earlier revision keeps working without being touched.
Allowed, with a revision bump:
- New endpoint.
- New optional field in a response. Clients must ignore unknown fields.
- New optional field in a request, with a default that preserves the old behaviour.
- New enum value, only where the contract already tells clients to treat
unknown values as a documented fallback (e.g. an unknown
item_kindis skipped, an unknownstatusis shown as "unknown"). Where it does not, the value needs a new field instead. - New
featureskey. - Relaxing validation (accepting more).
Not allowed in v1, ever:
- Removing or renaming an endpoint, field, query parameter or enum value.
- Changing a field's type or nullability.
- Changing a status code for a case a client already handles.
- Making an optional request field required.
- Tightening validation so a previously valid request fails.
- Changing the meaning of
404(which deliberately means "missing or not visible to you").
If a change you need is on the second list, either add a parallel field or
endpoint (v2-suffixed names are fine: /library/changes2) and deprecate the
old one (§5), or it is a /api/v2 discussion.
A contract change and its documentation land in the same commit: the
handler, the utoipa annotations that regenerate docs/api/openapi.json, the
revision bump, the CHANGELOG.md entry, and the client guide if the guide
needs prose.
docs/api/openapi.json is generated from the handlers' #[utoipa::path]
annotations (backend/src/http/openapi.rs); routes are registered through
routes!, so a documented route is a real one. Its info.version is
1.<revision>. Two checks run in CI:
cargo testfails when the committed file is not what the code describes (OPENAPI_WRITE=1 cargo test openapiregenerates it);scripts/check-api-contract.pycompares it with the last release tag and fails when an operation disappeared or the document changed without a revision bump.
An endpoint or field being retired:
- Is listed in
GET /api/v1/server→deprecationswithsince(revision) andsunset(date, at least 6 months out and never earlier than the next two minor server releases). - Answers with
Deprecation: trueandSunset: <http-date>headers. - Keeps working unchanged until the sunset date.
- Is removed only in a release whose changelog names it, and only after the hosted service has observed zero calls to it for 30 days.
Native app stores can take weeks to roll a client out, and users do not update promptly. Six months is the minimum, not the target.
One exception so far: YouTube channels as feeds were removed outright in revision 17
(1.0.0-beta.24). The feature had never worked in a released image, so nothing could
depend on it; see SCOPE.md decision 15.
Every error under /api/v1 is JSON: { "error": "..." }. Most handlers put
an English sentence there, not a code: clients branch on the status, never on
the text. The exceptions that are codes, and contract, are not_found,
feature_unavailable (below) and rate_limited.
A request for a feature this server does not offer (unconfigured narration,
billing on the open-source edition, SSO provider switched off) answers
501 Not Implemented with
{ "error": "feature_unavailable", "feature": "<features key>" } — not 404,
which already means "missing or hidden", and not 500. Clients that honour
features never see this; clients that do not get a stable, actionable answer.
A route that does not exist at all answers 404 { "error": "not_found" }.
StreamResponse.url (and every cover or download URL) is a string the client
fetches as given, within expires_in_secs. It may be a presigned S3 URL or a
route on the server itself (/api/v1/media/…), depending on where the file
lives. Clients never parse it, never persist it, and never assume its host.
Since revision 24, a server with a media proxy (STORAGE__MEDIA_PROXY_URL)
also sends fallback_url: the same audio by another host, for a network that
blocks url's (company firewalls that block cloud-storage domains). A client
that cannot load url — the host does not resolve or connect, or answers
403 from a proxy in between — tries fallback_url, and may keep using the
fallback on that network. The v2 stream link takes route=proxy for the same
choice: the server then redirects to the proxy instead of to the store.
Since revision 23, GET /api/v1/music/tracks answers with a weak ETag and
Cache-Control: private, no-cache; a request with that tag in If-None-Match
gets 304 Not Modified and no body while nothing the caller can see has
changed. A track's cover (GET /api/v1/music/tracks/{id}/cover) does the same
with a strong tag. Browsers and URLSession do this on their own; a client
that sends no If-None-Match gets the full answer as before.
- Access and refresh tokens are opaque to clients. Their format, lifetime and claims may change at any release without a revision bump.
- The refresh flow (
POST /auth/refresh) and its error codes are contract. - A server that cannot verify a token answers
401; clients then refresh once and, on a second401, sign out. This is already how every client behaves and it must stay so. - A signed-in caller without the right answers
403(since 2026-10-11; it was401, which made clients refresh a good token and sign out). Nothing was in production, so the status changed in place. - Known quirk, kept for v1: some handlers answer
401for an item that does not exist, where404would be right: family members, invites and join codes (/family/*), users (/users/*), jobs, uploads and statistics for another member. A client must not sign out on a401from these routes when a refresh succeeded. The fix waits for/api/v2(§11). Newer code answers404for missing or hidden items, as/podcasts/*, music and audiobooks do. - Access tokens live one hour by default (
AUTH__ACCESS_TTL_SECS); they were seven days until 2026-10-11. Clients refresh, as they already do. - Uploads and storing podcast episodes need
can_upload(403without it), andPOST /podcasts/subscribeanswers400for a feed on a private or local address (since 2026-10-11). - A password must be 12 characters or more when set (
400below that); it was 8 until 2026-10-11. Sign-in with an older, shorter password still works. POST /auth/loginanswers429for too many wrong passwords on one email (error: account_locked,Retry-Afterin seconds) as well as for the per-IP limit (error: rate_limited). Treat both as "wait", show the seconds; neither says whether the email exists.- Two-factor sign-in (revision 7): a sign-in call may answer
202with anmfa_token; see §3auth.totp. A client that does not handle it cannot sign such an account in, so handle it before anyone turns it on. - Password reset (revision 7):
POST /auth/password/forgotandPOST /auth/password/reset, see §3. A reset signs every session of the account out, refresh tokens included; the app signs in again. 402means the family has no room:POST /uploads/presign,POST /uploads/completeand storing a podcast episode answer it when a server'sSTORAGE__FAMILY_QUOTA_BYTESis reached (or, on a hosted server, the credit is used up). Show theerrortext; it says what is missing.- Browsers may call
/apionly from the console's origins (SERVER__CORS_ORIGINS, defaulting toSERVER__APP_BASE_URLandSERVER__BASE_URL);/restallows any origin. Native apps are unaffected.
- Migrations are forward-only and numbered. A release never edits a shipped migration. Downgrading a server is not supported; back up before upgrading (the installer prints how).
- The open-source server owns the whole core schema, including a few tables
the hosted edition uses and this edition does not (
credit_ledger,credit_alerts,stripe_events). They stay in the core sequence so that a database created by either edition can be run by either edition. Hosted-only tables created after the split live in their own Postgres schema (hosted) with their own migration table; the core never references them. - Skipping releases is supported: all pending migrations run on start.
Each release's CHANGELOG.md entry states the contract revision. Each client
repo's README states the minimum contract revision it needs and the
revision it was last tested against. The conformance suite
(conformance/, §10) is what "tested against" means.
Servers support every client released in the previous 12 months. Clients support every server at or above their stated minimum revision.
conformance/ is a black-box test suite that takes a base URL and
credentials and exercises the contract: auth, families, library sync,
playback, uploads, trash, Subsonic — and, from docs/api/openapi.json,
deny by default: every bearer-protected operation answers 401 without a
token and the instance-admin ones 403 to a plain member (suites/access.py),
and no operation carrying one family's ids answers 2xx to another family
(suites/isolation.py).
It passes against the open-source compose
stack and against the hosted canary in both repositories' CI. A features
key that is false skips that feature's tests rather than failing them.
The suite is the executable half of this document. When the suite and this document disagree, fix whichever is wrong in the same commit.
Changes v1 cannot make (§4), collected so a v2 is one deliberate step:
404instead of401for a missing item in/family/*,/users/*, jobs, uploads and statistics (§7).- Error bodies with a machine-readable
codeon every error, beside the humanerrortext.