This is the frozen version 1 of the protocol, dated 11 September 2026. Kithara's Android client implements it today; the server is yours to write, and the interesting work (chapter parsing, the combined timeline, conflict resolution, offline queueing) already lives on the phone. Server libraries are a Pro feature.
1. Conventions
-
Times are integer milliseconds. Durations and positions are
msfrom the start of the book's combined timeline. Timestamps are Unix epoch milliseconds, UTC. No floats, no seconds, no ISO strings. -
JSON, UTF-8,
Content-Type: application/jsonboth ways. -
Unknown fields are ignored. Servers MAY add fields; clients MUST ignore fields they do not know. This is how the protocol grows without a version bump. Removing or re-typing a field is a version bump.
-
Authentication is
Authorization: Bearer <token>on every route except/api/healthand/api/auth. -
Ids are opaque strings chosen by the server. They MUST be stable for the life of the item (survive renames, re-scans and restarts: derive them from a stored id, not from a file path) and unique within the server. Clients hash
(server, id)for local storage and send the id back in URL paths percent-encoded, so any string works, but[A-Za-z0-9._-]keeps logs readable. -
Errors are any non-2xx. The body SHOULD be
{ "error": "human readable" }. Clients treat status codes as follows and servers SHOULD pick accordingly:Status Meaning to the client 401Token is no longer valid. Stop retrying; ask the user to sign in again. 404on a book routeThe book is gone. Drop the queued write. 409Rejected as stale (see §5). Not an error; adopt the body. 429,503Busy or reindexing. Retry later. Queued writes survive restarts. anything else non-2xx Retry later; give up on a single write after repeated failures. -
Transport. HTTPS SHOULD be used anywhere off a private network; the token is a bearer credential. Clients accept plain
http://for home networks. -
Users. The token identifies one user. Everything under
/api/progressand/api/bookmarksis that user's, and only that user's. The library MAY be shared between users or per user; the client does not care.
2. GET /api/health
Unauthenticated. Proves the address is a Kithara server before the user commits to it.
{ "ok": true, "protocol": 1, "name": "Phil's shelf", "version": "0.3.1" }
protocol(integer) is this document's version. A client compares it against the versions it implements: equal → proceed; server lower → proceed using only that version's routes; server higher → refuse with "server is newer than this app". Servers MUST send it. (A server that omits it is treated as protocol 1.)nameMAY be shown as the default library name in the client.versionis the server software's own version string, for display only.
3. POST /api/auth
{ "username": "phil", "password": "…", "device": "Pixel 9" }
{ "token": "long-lived-opaque-token", "user": "phil" }
- The client stores the token and forgets the password. Tokens SHOULD be long-lived;
there is no refresh flow. When a token stops working the server answers
401and the client asks for the password again. deviceis optional, for the server's own session list.userin the response is optional and cosmetic.- Servers MAY support
POST /api/auth/logout(bearer, empty body) to revoke the token. Clients MAY call it when a library is removed and MUST NOT depend on it.
4. GET /api/library
The whole catalogue. Called on a full sync, not on every launch.
{
"updatedAt": 1737000000000,
"books": [
{
"id": "the-hobbit",
"title": "The Hobbit",
"author": "J. R. R. Tolkien",
"narrator": "Rob Inglis",
"series": "Middle-earth",
"seriesIndex": 1.0,
"description": "Bilbo Baggins is a hobbit who enjoys a comfortable life…",
"durationMs": 39600000,
"coverUrl": "/covers/the-hobbit.jpg",
"updatedAt": 1737000000000,
"files": [
{ "index": 0, "name": "hobbit-part-1.m4b", "url": "/audio/the-hobbit/part-1.m4b",
"durationMs": 19800000, "sizeBytes": 284000000, "mimeType": "audio/mp4" },
{ "index": 1, "name": "hobbit-part-2.m4b", "url": "/audio/the-hobbit/part-2.m4b",
"durationMs": 19800000, "sizeBytes": 281000000, "mimeType": "audio/mp4" }
],
"chapters": [
{ "title": "An Unexpected Party", "startMs": 0, "endMs": 2760000 },
{ "title": "Roast Mutton", "startMs": 2760000, "endMs": 5100000 }
]
}
]
}
Required per book: id, title, files (at least one, each with url and
durationMs), updatedAt. Everything else is optional; durationMs defaults to the sum
of the files.
Semantics that matter:
- The list is authoritative. A book absent from the response has been removed from the server, and the client deletes its local copy (keeping nothing but downloaded audio until the user clears it). Do not omit books to save bandwidth.
updatedAtis the whole caching story. The client keeps the last value it saw per book and skips any book whoseupdatedAthas not moved. Bump it whenever files, chapters, cover or metadata change; a library of 500 books then re-syncs in one cheap request. Deriving it from the newest file mtime is fine. The top-levelupdatedAtis optional and lets a client skip the whole body when nothing changed (ETag/If-None-Matchon this route is also welcome and works the same way).filesare ordered byindexand concatenated into one timeline. Positions, bookmarks andchaptersare all expressed against that combined timeline, never per file. A chapter MAY span a file boundary.chaptersMAY be empty. With more than one file the client shows one chapter per file. With a single file it shows none: clients do not re-parse the container over the network. If your files have embedded chapters, parse them server-side once and send them.urlandcoverUrlMAY be relative (resolved against the server root) or absolute. Both are fetched with the bearer token.mimeTypeper file is optional; clients sniff the container regardless.- Covers: JPEG or PNG, ideally 600–1200 px square. Fetched once per
updatedAtand cached on the device. - No pagination in version 1. Libraries in the low thousands of books are fine as one response; gzip helps.
5. Progress
One record per book per user. This is the part that makes two devices feel like one.
{ "bookId": "the-hobbit", "positionMs": 8130000, "updatedAt": 1737002000000,
"finished": false, "speed": 1.25 }
positionMs: where the user is, on the combined timeline. Clamp to[0, durationMs].updatedAt: the client's clock at the moment the position was set, not the time of the request. A position recorded offline on Monday and uploaded on Wednesday carries Monday's timestamp. This is what conflicts are settled on.finished: the user reached the end or marked it finished. Restarting a book sendsfinished: false, positionMs: 0.speed: optional playback-speed override for this book. Servers MUST store and echo it if sent and MAY ignore it otherwise. Clients MAY use it.
GET /api/progress
Every record for this user, in one response. Called on every app resume and after every pause, so keep it cheap: it is a small table scan.
{ "progress": [ { …record… }, { …record… } ] }
PUT /api/progress/{bookId}
Body is one record. The server applies last-write-wins on updatedAt:
- If the incoming
updatedAtis greater than or equal to the stored one (or nothing is stored): store the record exactly as sent: do not replaceupdatedAtwith the server clock: and answer200with the stored record. - If the incoming
updatedAtis older than the stored one: keep the stored record and answer409with the stored record in the body. The client treats this as "someone else got further" and applies the body under the rules below. This is not an error and MUST NOT be logged as one.
Answering 200 and storing the newer of the two also satisfies the contract; 409 just
lets the client learn the winner one round-trip sooner.
How a client resolves conflicts
The device applies a server record only when both hold:
- the server's
updatedAtis newer than the device's for that book, and - the device has nothing queued for that book.
Rule 2 is the one that matters in practice. Listen through a flight with no signal, land, open the app: the device has three hours of newer listening and the server has a stale position with a newer-looking timestamp from another device that synced yesterday. Without rule 2 you would be silently rewound. The device therefore pushes first, then pulls.
Clock skew between devices is not handled beyond this. Two devices whose clocks disagree by
minutes will occasionally pick the wrong winner; that is acceptable for a home server and
not worth a vector-clock scheme. Servers SHOULD keep NTP time so at least their own
timestamps (library updatedAt) are sane.
Cadence
Clients push when playback pauses or stops, when a book is finished/restarted, and on a
sync, not on a timer during playback. Expect a handful of PUTs per listening session, not
one per second. A server MAY answer 429 if a client misbehaves.
6. Bookmarks
Per user, per book, on the combined timeline. Version 1 adds read-back and deletion so a bookmark made on one device appears on the other.
{ "id": "bm_8f3a", "bookId": "the-hobbit", "positionMs": 5100000,
"label": "the riddle game", "createdAt": 1737001000000 }
GET /api/bookmarks
Every bookmark for this user, all books, one response:
{ "bookmarks": [ { …bookmark… } ] }
POST /api/bookmarks/{bookId}
Body: { "positionMs", "label", "createdAt" }. Response 201 with the stored bookmark
including its server-assigned id. Servers MUST treat a repeated POST with the same
(bookId, positionMs, createdAt) as the same bookmark and return the existing one, so an
uncertain client can retry without creating duplicates.
DELETE /api/bookmarks/{bookId}/{id}
204 on success, 404 if already gone (which the client treats as success).
7. Serving the audio
The url of each file is fetched with Authorization: Bearer <token> and MUST support:
- HTTP range requests (
Accept-Ranges: bytes,206 Partial Content). Seeking into hour nine of an m4b is a range request; without this, seeking re-downloads from the start. - A correct
Content-Length. Downloads show no progress without it. - A sensible
Content-Type(audio/mp4,audio/mpeg, …). Players sniff the container anyway, so a wrong type is survivable, but a right one starts faster.
HEAD on file URLs SHOULD work (clients use it to size a download before starting).
nginx does all of this out of the box for static files, including the bearer check with
auth_request.
8. What is deliberately not in version 1
- Listening statistics and achievements stay on the device. Cross-device stats are a candidate for version 2, as append-only listening sessions.
- Library edits (renaming, re-tagging, uploading): the server's own UI or filesystem does that.
- Push notifications / real-time. Clients poll on resume and after pauses; that is enough for one person moving between devices.
- Multiple libraries per server. One server, one catalogue. Run two servers if you want two.
9. Minimum viable server
You do not need a database. A directory scanner that emits /api/library from the
filesystem, a JSON file per user for progress and bookmarks, and static file serving is a
complete implementation.
The one thing to get right on day one is stable ids: assign each book an id when it is first seen and persist it (a sidecar file, a small key-value store), so renaming a folder does not turn one book into a new one and orphan its position.
10. Client conformance: Android
What the Android client implements today, so the next client knows where the spec runs ahead of the code:
| Section | Status |
|---|---|
§2 health, protocol check |
implemented |
§3 auth, 401 → sign-in required |
implemented; device/logout not sent |
§4 library, authoritative list, updatedAt skip |
implemented; ETag not used |
| §5 progress push/pull, conflict rules | implemented; 409 body adopted; speed not sent |
| §6 bookmarks POST/GET/DELETE | implemented; deletions are tombstoned locally until the server confirms |
| §7 range playback, downloads | implemented |
Changelog
- 1 (2026-09-11): first frozen version. Relative to the earlier draft:
protocolfield in health; error-code table; percent-encoded ids; server-side last-write-wins with409; optionalspeed;descriptionandmimeType; bookmark ids, read-back and deletion; authoritative library list stated explicitly; explicit non-goals.
Questions
Building a server and hit something the spec does not cover? Get in touch; we would rather fix the document than have you guess.