The contract for firmware (CrossPoint / CrossInk) and any other client. Two API surfaces share one account system and the same auth headers:
- KOSync-compatible API at the root — byte-compatible with
sync.koreader.rocks. Stock KOReader and current CrossPoint firmware work by changing only the server URL. - Extended API under
/api/v1— lossless CrossPoint positions, bookmarks, clippings, reading stats, and document metadata.
All requests and responses are JSON (Content-Type: application/json). The
Accept: application/vnd.koreader.v1+json header is accepted but never required.
Every endpoint except POST /users/create and GET /healthz requires:
x-auth-user: <username>
x-auth-key: <MD5 hex of the account password>
The key is the lowercase 32-hex MD5 of the plain password — exactly what KOReader and CrossPoint already send. The server never sees the plain password and stores only a salted PBKDF2 of the key.
Errors reuse the stock kosync codes so existing client error handling keeps working:
| HTTP | code | Meaning |
|---|---|---|
| 401 | 2001 | Missing/invalid auth headers |
| 402 | 2002 | Username already registered |
| 403 | 2003 | Invalid request body / registration disabled |
| 403 | 2004 | Missing or malformed document |
| 429 | 2001 | Rate limited (registration / repeated auth failures) |
Body shape is always {"code": 2001, "message": "Unauthorized"}.
document is an opaque key chosen by the client, up to 64 chars of [A-Za-z0-9._-]. CrossPoint
sends the KOReader 32-hex MD5 (partial-binary or filename method, per device setting). The server
never tries to unify the two hash methods; a book synced under both appears as two documents.
Open registration (disable with REGISTRATION_DISABLED=true).
// request
{"username": "justin", "password": "0f359740bd1cda994f8b55330c86d845"}
// 201 response
{"username": "justin"}Usernames: 1–64 chars of [A-Za-z0-9._@+-]. The password field is the MD5 auth key.
Validates credentials. 200 {"authorized": "OK"} or 401.
// request
{
"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"progress": "/body/DocFragment[8]/body/div[2]/p[4]/text()[1].96",
"percentage": 0.4867,
"device": "CrossPoint",
"device_id": "crossink-device"
}
// 200 response
{"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "timestamp": 1752345678}-
One row is stored per
(user, document, device_id)— unlike stock kosync, devices never overwrite each other. Ifdevice_idis absent,deviceis used as the device key. -
percentageis a float in[0, 1].timestampis unix seconds (integer). -
Superset capture: the body may include the extended
positionobject (seePUT /api/v1/progress) and/or ametadataobject (below). If present and valid they are stored; unknown fields are ignored. This lets firmware ship rich sync against this endpoint with a one-line change. -
Metadata capture (KOReader PR #15306 shape, sent by CrossPoint when the "send metadata" setting is on):
"metadata": { "filename": "Foundryside - Robert Jackson Bennett.epub", "title": "Foundryside", "authors": "Robert Jackson Bennett", "language": "en", "bookfusion_id": "36835" }
All fields optional strings (≤512 chars).
filename/title/authorsare stored per(user, document); fields the client omits never overwrite previously stored values. Retrieve viaGET /api/v1/documentsor joined intoGET /api/v1/progress.Language.
languageis the EPUB'sdc:languageas a BCP 47 tag ("ja","de-DE"). It only fills a blank, so a language set by hand throughPUT /api/v1/documents/{document}/infostays. A value that isn't a language tag is ignored; the sync itself still succeeds.Service ids. Any extra
<service>_idfield (from a CrossPoint plugin's book sidecar, e.g.bookfusion_id) pre-seeds an exact match for the connector of that name: progress is pushed straight to that record, skipping title/author search. Unknown-connector ids are ignored; a user's manual match is never overridden.BookFusion positions. For a BookFusion download identified by its sidecar, the server retrieves the EPUB and resolves the existing KOSync XPath against its chapter XHTML. It sends the resulting CFI, chapter index, and text-based spine-normalized position to BookFusion. This requires no additional firmware fields. Precision follows the supplied XPath: a text-node offset identifies that text position; a paragraph/chapter-only XPath identifies its start. An unresolved XPath fails without posting an estimated position.
When a device requests
GET /syncs/progress/:documentorGET /api/v1/progress/:document, the server first fetches the matched BookFusion book's latest position, resolves its point CFI in the EPUB, and then reads the stored progress for the response. This requires a linked, enabled account and a previously received sidecar match; a GET cannot discover metadata that the device has not sent. BookFusion is not polled in the background. Concurrent requests for the same user's book share a lookup; subsequent requests check the provider again.Refreshes have a 10-second total deadline. A timeout returns HTTP 504; provider or conversion failures return HTTP 502. Stored progress is preserved and the next GET retries the refresh. Books without an enabled sidecar match use the normal KOSync response. No firmware update is needed. Conversion uses the provider's update timestamp, skips older positions, and preserves text offsets (converting UTF-16 CFI offsets to CrossPoint codepoints). Delayed outbound events also check the remote timestamp before posting, to avoid replacing newer BookFusion progress. The provider does not offer an atomic conditional update, so a simultaneous edit between that check and the POST is still possible.
BookFusion's storage ignores Range requests, so an EPUB is downloaded whole (limited to 200 MiB and 30 seconds), and only its text entries (container, package, and XHTML) are decompressed; each is limited to 1 MiB. The server then stores a redacted position map per linked book in
epub_maps: chapter text is replaced with same-length filler and onlyidattributes are kept, so no readable book text is stored, yet positions resolve identically. Later syncs use the map without downloading; a position that fails against it triggers one fresh download and rebuild (the book may have changed). Maps are removed when the connector is unlinked or the account deleted, and pruned when their book is no longer matched or unused for 30 days. Books matched by title or manual selection retain percentage-only outbound updates and are excluded from inbound position conversion because those matches do not establish that the reader has the same EPUB edition.
Returns the newest progress across all of the user's devices (most recent timestamp wins):
{
"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"progress": "/body/DocFragment[8]/body/div[2]/p[4]/text()[1].96",
"percentage": 0.4867,
"device": "CrossPoint",
"device_id": "crossink-device",
"timestamp": 1752345678
}Quirk (stock-compatible): if no progress exists, the response is 200 with {} — not 404.
KOReader clients check for field presence, not status codes.
Same auth headers. Designed for the ESP32: responses are small (< 8 KB at default limits), keys are short, batches are capped.
GET ...?since=<unix>&limit=<n>returns items withupdated_at > since, oldest first, including tombstones ("deleted": 1).limitdefaults to 50, max 100.- The response carries
until(persist it as the nextsincecursor) andmore(repeat withsince = untiluntilmoreis false). PUTbatches setupdated_aton the server clock — the server is the last-write-wins authority because device RTCs drift. A delete is{"id": "...", "deleted": 1}(no other fields needed). Deleted items keep tombstones forever so late-syncing devices converge.- Merge semantics: set-union of ids, last-write-wins per id.
The kosync body plus a position object mapping 1:1 to the firmware's CompactPosition:
{
"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"progress": "/body/DocFragment[8]/body/div[2]/p[4]/text()[1].96",
"percentage": 0.4867,
"device": "CrossPoint",
"device_id": "A1B2C3D4",
"position": {
"pctQ": 486700,
"spine": 7,
"page": 143,
"pages": 412,
"para": 96,
"li": 0,
"anchor": "ch08-sec2",
"xpath": "/body/DocFragment[8]/body/div[2]/p[4]/text()[1].96"
}
}| field | type | notes |
|---|---|---|
| pctQ | uint | percentage × 1,000,000 (0–1,000,000). Layout-independent; authoritative. |
| spine | uint16 | spine (chapter) index |
| page / pages | uint16 | page within spine / spine page count — layout hints, depend on font settings |
| para | uint16, optional | synthetic 1-based paragraph index; omit when unavailable (hasParagraphIndex=false) |
| li | uint16, optional | running <li> count; omit when unavailable |
| anchor | string ≤ 48 bytes, optional | nearest <a id> anchor |
| xpath | string ≤ 120 bytes, optional | KOReader-style xpath |
Response: {"document": "...", "timestamp": 1752345678}.
An optional timestamp (unix seconds) says when the position was read, for clients that sync
later than they read. The stored time, the reading timeline and "newest wins" then use it instead
of the time of the request. A time in the future is capped at now; a missing one, or one before
2000, means now. PUT /syncs/progress and the batch route always use the time of the request, so
a reader with an unset clock can't backdate itself.
An invalid position is ignored (the kosync fields still sync); a missing position on a later
PUT keeps the previously stored one for that device.
Several books' progress in one request, for a reader's bulk sync push: {"items": [...]} with 1 to
20 items, each a PUT /syncs/progress body (including the optional position and metadata).
Every item is stored exactly as that route stores it: under the document's canonical hash after a
merge, with a feed sequence number, reading history, and connector fan-out.
All or nothing: if any item is invalid the request is rejected (403, kosync error body) and nothing is written. Response:
{"accepted": 2, "items": [{"document": "a1b2...", "timestamp": 1752345678},
{"document": "25f8...", "timestamp": 1752345678}]}items[].document echoes the hash each item was sent with, in request order. Servers without this
route (stock kosync, older crosspoint-sync) return 404; readers then send one PUT /syncs/progress
per book.
Lists every synced document — newest progress across devices, joined with any stored metadata.
This is how clients/UIs discover documents without knowing hashes. ?limit= defaults to 100
(max 500), ordered newest-first. next is null on the last page; otherwise pass it back as
?cursor= for the following page. A malformed cursor is rejected with 403.
{
"items": [
{"document": "25f8abb4f4f5594f02f361726814fea1",
"title": "Foundryside", "author": "Robert Jackson Bennett",
"filename": "Foundryside - Robert Jackson Bennett.epub",
"percentage": 0.2853, "progress": "/body/DocFragment[16]/body/div[1]/p[143]",
"device_id": "crosspoint-reader", "device": "CrossPoint", "timestamp": 1783913361}
],
"next": "1783913361:25f8abb4f4f5594f02f361726814fea1"
}title/author/filename are null until some client sends metadata for that document.
Items also carry status, status_at (when a manual status was set, else null), cover_url, page_count, and book details from Hardcover's catalog
when the server sets HARDCOVER_API_KEY: hardcover_slug, moods, genres, content_warnings
(lists), rating, series, series_position, release_year (empty lists / null until looked
up; lookups run in the background, a few books per list request, one per distinct book across users).
All device rows, newest first — the client decides what to apply:
{
"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"devices": [
{"device_id": "A1B2C3D4", "device": "CrossPoint", "percentage": 0.4867,
"progress": "...", "position": { "pctQ": 486700, "spine": 7, "page": 143, "pages": 412 },
"timestamp": 1752345678},
{"device_id": "koreader-boox", "device": "boox", "percentage": 0.41,
"progress": "...", "position": null, "timestamp": 1752300000}
]
}position is null for rows written by plain kosync clients.
Progress change feed for incremental device sync: documents whose current position changed since a cursor, oldest change first.
Query: since (cursor from the previous page, default 0 for a full snapshot), limit (default
20, max 50; 0 returns only the current cursor), device (the caller's device_id).
{
"cursor": 4812,
"more": false,
"changes": [
{"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "aliases": ["ffeeddccbbaa99887766554433221100"],
"percentage": 0.4213, "progress": "/body/DocFragment[12]/body/p[3]/text().0",
"position": null, "device": "Phone", "device_id": "phone", "timestamp": 1791234567}
]
}- One entry per document: its newest row (same tie-break as the kosync GET), included only when that
row changed after
since.documentis canonical;aliaseslists merged hashes. - Every progress write (kosync and v1 PUTs, connector fan-in, document merges) stamps a server-wide
sequence number on the row, so the cursor does not depend on
updated_at(fan-in keeps the external service's timestamp). If a write makes an existing row become newest, that winning row also receives a fresh sequence so the feed reports the effective position change. - A document whose newest row was written by
deviceis left out; there is no fallback to an older row from another device. Server-side merges and switches to an existing row bypass this exclusion until the cursor passes that server-side change. The cursor still moves past excluded rows. cursoris where the next request should start; repeat withsince = cursorwhilemoreis true. A page also stops before its body passes 8 KB, but always holds at least one change.- Servers without this route (stock kosync, older crosspoint-sync) return 404; devices fall back to per-book requests.
Progress history (progress_log, which feeds reading days and streaks) only records a write whose
percentage differs from the previous value. A device's first write for a document compares against
the legacy shared crossink-device row when the id is a per-reader crossink-… id and that row
exists, otherwise against the document's newest row from any device.
Removes a synced book completely. Deletes the kosync progress for every device plus everything
else stored server-side for that book: position samples, bookmarks, clippings, per-book reading
stats, connector matches and any queued connector events. Document metadata goes too, so the book
disappears from GET /api/v1/progress and GET /api/v1/documents, and GET /syncs/progress/{document} goes back to returning {}.
{"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "deleted": true, "rows": 14}rows is the number of database rows removed. Returns 404 {"code": 2003, "message": "Unknown document"} when the user has no data for that document.
Bookmarks and clippings are hard-deleted rather than tombstoned — there is no book left to delta-sync against. A device that still holds the file simply re-uploads its state on the next sync, so this is a server-side reset, not a device-side delete.
Item ids are client-derived: id = first 16 hex chars of SHA-256(xpath). Deterministic, so
re-adding the same bookmark is idempotent and delete-by-id needs no new device-side state. All
CrossPoint devices must compute ids identically.
{
"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"until": 1752345678,
"more": false,
"items": [
{"id": "9f86d081884c7d65",
"xpath": "/body/DocFragment[3]/body/p[12]/text().0",
"percentage": 0.35,
"summary": "It was the best of times, it was the worst of",
"si": 3, "pc": 120, "pp": 42,
"deleted": 0, "updated_at": 1752340000}
]
}si/pc/pp are CrossPoint's spine index / chapter page count / page-in-chapter hints
(nullable — layout dependent).
Batch upsert, max 50 items:
{"items": [
{"id": "9f86d081884c7d65", "xpath": "...", "percentage": 0.35,
"summary": "...", "si": 3, "pc": 120, "pp": 42},
{"id": "deadbeef00112233", "deleted": 1}
]}Response: {"until": 1752345679, "accepted": 2}.
Mirrors CrossInk's Clipping struct (src/ClippingStore.h) 1:1. Item ids are client-derived:
id = first 16 hex chars of SHA-256(created_at_decimal + text) — both fields are immutable after
creation.
Item shape:
{
"id": "c0ffee0011223344",
"spine": 7,
"start_page": 12, "end_page": 13, "pages": 40,
"start_word": 5, "end_word": 22, "words": 30,
"para": 96,
"chapter": "Chapter 8",
"text": "So we beat on, boats against the current...",
"note": null, "color": null,
"created_at": 1752300000,
"deleted": 0, "updated_at": 1752340000
}paraoptional — omit when the firmware hasparagraphIndex == UINT16_MAX.- Page/word fields are layout hints, kept verbatim for CrossInk↔CrossInk restore;
para+textare the portable anchors. text≤ 4096 UTF-8 bytes,chapter≤ 64 chars,note≤ 4096 bytes.noteandcolorare preserved when omitted from updates.- No per-book cap server-side; the current firmware stores up to 256 active clippings per book.
Endpoints: GET /api/v1/clippings/{document}?since=&limit= and
PUT /api/v1/clippings/{document} (batch max 50).
Clipping clients should use GET ...?cursor=0&limit=1&format=reader. Responses
advertise sync_version: 2, include each item's revision, and return a monotonic
cursor for the next page. Continue until more is false. This avoids losing
items that share a second-resolution timestamp. format=reader omits note and
color to bound firmware response memory. Legacy since requests remain supported.
start_offset and end_offset are optional chapter-visible Unicode-codepoint
positions, [start, end), independent of pagination. Supply both or neither.
layout_signature preserves compatibility with older page-local word ranges.
A deleted ID cannot be resurrected by a delayed upload; create a new ID to save
the same quote again. Enable Sync Clippings in the firmware's CrossPoint Sync
settings to sync clippings alongside the current book's manual progress sync.
Model (matches the firmware's nearby P2P stats sync): each device uploads its own snapshot; snapshots replace, never accumulate; the server aggregates across devices on read. Never merge another device's stats into local ones.
BookOrbit reading sessions are imported separately for matched books already known to Sync, including their complete historical session lists. Existing links are picked up automatically; linking or Sync now starts a background pass. The worker continues every minute, processes up to three books and five 100-session pages per book per pass, and refreshes completed imports hourly. It also tries matching up to three older/finished local books per pass. Books that exist only in BookOrbit are not added to the Sync library by this import.
A book's new totals become visible only after all its pages have been validated. Session IDs
deduplicate retries and duplicate book matches. Full reconciliation replaces edited/deleted
sessions; failed requests retain the last complete import. Reading time, session counts, daily
time, history, and time/day histograms join the existing stats responses as a virtual
connector:bookorbit device named BookOrbit. Page counts and completed-book counts are not
inferred from session durations. Calendar days use the BookOrbit account's timezone (UTC fallback),
splitting sessions across local midnight; historical daily rows are retained beyond the bitmap's
730-day window.
Overlap policy: include every BookOrbit source, including KOReader on Kindle, Kobo, manual entries, and missing/unknown sources, alongside direct device stats. Having a device snapshot, the same reading date, or an equal duration is not evidence of a duplicate. Repeated BookOrbit session IDs count once. Rows with different IDs also count once when the external book ID, source, start timestamp, end timestamp, and duration all match exactly. Entries without a positive time span are matched only by session ID. Different books, different sources, and partial time overlaps remain separate. Original imported rows are retained.
Direct device snapshots contain cumulative totals without session IDs, source provenance, or
time intervals. They cannot prove overlap with a BookOrbit session, so no cross-source subtraction
is guessed. If the same physical device's stats are independently uploaded directly and through
BookOrbit, overlap between those channels still cannot be resolved automatically with this
contract. Sync does not export reading stats to BookOrbit, so there is no stats export/import loop.
The stats responses include bookorbit: { excluded_sessions, excluded_seconds, reason } for
identifiable repeated records only; the app explains when such duplicates were counted once.
Imported time is never converted into progress-log entries or extra page/sync counts.
Unlinking removes imported stats; relinking clears the old import, and changing
the BookOrbit server/user also clears stale matches, queued exports, and EPUB maps.
The device's GlobalReadingStats as JSON:
{
"device_id": "A1B2C3D4", "device": "CrossInk",
"v": 5,
"sessions": 312, "seconds": 184300, "pages": 9120, "completed": 14,
"tod": [1200, 84000, 60100, 39000],
"dow": [8000, 9000, 7000, 11000, 12000, 60000, 77300],
"anchor_day": 9650,
"history_b64": "<base64 of the 92-byte reading-history bitmap>",
"streak": 21
}tod= seconds in [morning, afternoon, evening, night];dow= Mon..Sun seconds.anchor_day= days since 2000-01-01 of the bitmap's most recent day; bit N of the bitmap =anchor_day - N(LSB-first within each byte, 730 days / 92 bytes).streak= device-reported all-time longest streak (may predate the bitmap window).
Response: {"until": 1752345678}.
Uploads measured reading-session checkpoints alongside the existing counters.
{"device_id":"reader-id","items":[{"session_id":"stable-session-id","document":"0123456789abcdef0123456789abcdef","started_at":1609502400,"ended_at":1609503000,"seconds":600,"utc_offset_minutes":0}]}Accepts 1–20 records. IDs are scoped to the authenticated user and device;
session_id is 1–64 ASCII letters, digits, _ or -, and document is a
32-hex reader document ID. Timestamps are integer Unix UTC seconds; the offset
records the device's local timezone at capture. Active seconds must be at least
10, no greater than the wall-clock span, and the span cannot exceed seven days.
Future timestamps beyond five minutes are rejected. Undated reading remains in
legacy counters and is not manufactured into dated sessions.
Checkpoints retain their ID and original book, start, and timezone. Later checkpoints may increase both duration and end time; exact or older checkpoints are acknowledged without decreasing stored values. Inconsistent identities or mixed-direction updates reject the complete batch.
Returns {"until":1609503100,"accepted_session_ids":["stable-session-id"]}.
Readers must verify the specific acknowledged IDs before clearing their outbox.
HTTP success or an old server's generic stats response alone is insufficient.
CrossInk keeps session capture in RAM and writes it at existing reader save points (progress saves, sleep/exit, tracking disable and restart handoffs), without an additional per-minute session timer or extra book/global counter snapshots. Stable IDs still allow cumulative updates at successive save points.
Sessions do not add to device totals, book totals, daily counters, or streaks: those existing snapshots remain authoritative. Linked, enabled BookOrbit accounts export measured sessions for matched books in bounded background passes. A stable provider ID makes timeout retries and later checkpoints update the same session. Old aggregate history remains intact; it is not converted into invented sessions. XTC stats-only uploads require an existing/manual match because they carry no book title or author metadata.
BookOrbit currently returns numeric IDs rather than the submitted client ID.
Sync saves a complete preflight ID boundary, verifies the new exact row after
upload, and retains its numeric ownership mapping across unlink/relink. An
uncertain initial export defers that book's history reconciliation while keeping
the previous published history. Verified exported rows are excluded individually
from imported stats; independent Kindle/other reader sessions remain included.
BookOrbit's current client API records these exports as web reading sessions.
Export errors appear with the connector's queue_errors and retry in the
background (or on Sync now). Account switches use separate ownership mappings.
Batch (max 20) of per-book stats_v5 snapshots for one device:
{"device_id": "A1B2C3D4", "items": [
{"document": "a1b2c3d4e5f60718293a4b5c6d7e8f90", "v": 5,
"sessions": 9, "seconds": 8400, "pages": 310, "completed": false,
"avg_fwd": 12, "pace_n": 250, "eta": 5400,
"start_manual": false, "finish_manual": false,
"start_date": 1751000000, "finished_date": 0,
"tod": [0, 3000, 4000, 1400], "dow": [0, 0, 1200, 0, 2000, 3000, 2200],
"daily": [{"date": "2026-10-23", "seconds": 900}]}
]}daily is optional: the book's seconds per local day, up to 20 per item, in the same shape as the
global upload's. A day sent again keeps the larger value, so a client can resend today as it grows.
The answer counts them as accepted_daily.
Server-side aggregate across all of the user's devices:
- scalars and
tod/doware summed; - history bitmaps are OR-ed after re-anchoring to the newest
anchor_day(each device's bits shift by its anchor delta; days older than the 730-day window drop); streak= max(longest run in the combined bitmap, any device-reported streak);current_streak= run ending at the anchor day (the anchor day itself may be unset — today isn't over).
{
"sessions": 402, "seconds": 190300, "pages": 9500, "completed": 15,
"tod": [...], "dow": [...],
"anchor_day": 9650, "history_b64": "...", "streak": 21, "current_streak": 6,
"devices": [{"device_id": "A1B2C3D4", "device": "CrossInk", "updated_at": 1752345678}]
}A small digest for readers that want to show the account's totals on the device. It has the same numbers the Stats page shows, from every source: syncs, chosen start and finish dates, and device stats.
{"anchor_day": 9793, "days": 365, "history_b64": "...", "reading_days": 53,
"current_streak": 2, "longest_streak": 6, "books_finished": 496, "finished_this_year": 31,
"pages_read": 142177, "seconds": 2880, "sessions": 0, "today_seconds": 420, "week_seconds": 2880}tzis the caller's UTC offset in minutes as JSgetTimezoneOffset()gives it. It decides what "today" and "this year" are.anchor_dayis today, as days since 2000-01-01. Inhistory_b64, bit N (LSB-first per byte) is set when dayanchor_day - Nwas a reading day. The bitmap covers the lastdaysdays (default 365, at most 3650), so the response stays a few hundred bytes.- All the counts cover the whole history, whatever
daysis:reading_days, the streaks, the finished counts and the pages. The time fields come from device stats:seconds,sessions,today_seconds, andweek_secondsfor the last 7 days including today. Withweek_start=0(Sunday) orweek_start=1(Monday),week_secondscovers the calendar week so far instead, in every group too. Any other value is refused.
Filters narrow the digest to matching books: language (main subtag, so en also matches en-GB),
genre (one of the book's genres) and author (part of the name). All ignore case and can be combined.
A filtered digest counts only per-book data: the books' sync and session days, their finished counts and
pages, and their measured time (per-book stats and reading sessions). Device-wide totals with no book
attached are left out. Reading days and time per day come from the books' uploaded days, reading
sessions and imported time; where a book's sessions and its devices' days measure the same day, the
larger one counts.
group_by=language or group_by=genre adds groups, one digest per value, for the 20 values with the
most reading days. A book counts in each of its genres. The top-level fields stay the account's (or the
filter's, when one is given).
{"anchor_day": 9793, "...": "...", "groups": {"ja": {"reading_days": 88, "...": "..."}, "de": {"...": "..."}}}Raw per-device snapshots ({"devices": [{"device_id", "device", "updated_at", "stats": {...}}], "until": ...})
for clients that render per-device numbers like the P2P screen does. Each device's stats.daily lists its days.
device_id keeps one device. since keeps only the days uploaded at or after that time: pass the until of an
earlier answer to fetch what changed since then. Imported BookOrbit time comes whole whenever its import changed.
Per-device rows plus a combined object (scalars/buckets summed, completed OR-ed, avg_fwd
weighted by pace_n, start_date = earliest non-zero, finished_date = latest). Each row's
stats.daily holds that device's days for the book; combined.daily sums every device per date. A
device restoring its own history reads its own row, so another device's time is never folded in.
Every book's stats, one item per device and book, ordered by document. Each item carries the book's
title, author, filename and language, so a client can match it to a file without knowing the
hash, plus stats with daily as above. Items are stored rows: a merged copy is listed under its own hash, where
GET /stats/books/{document} folds it into its book. device_id keeps one device's items. limit defaults to 50
(at most 200); pass the answer's next, URL-encoded, as cursor for the following page, until it is null.
Treat it as opaque: its format may change.
since keeps the books uploaded at or after that time: pass the until of an earlier answer (its first page's) to
fetch only what changed since then.
{"items": [{"device_id": "A1B2C3D4", "document": "a1b2...", "title": "Kokoro", "author": null,
"filename": "kokoro.epub", "language": "ja", "updated_at": 1761300000,
"stats": {"v": 5, "sessions": 9, "...": "...", "daily": [{"date": "2026-10-23", "seconds": 900}]}}],
"next": "a1b2...:A1B2C3D4", "until": 1761300100}Merged documents (POST /api/v1/documents/merge, {"document", "into", "stats"?}): stats rows
stay under the hash each device uploaded them with and are combined on read. With "stats": true
(the default) the alias's rows count toward the canonical book, and each row carries its
document; with "stats": false they are left out. Unmerging hands the alias its stats back.
PUT /api/v1/documents — {"items": [{"document": "...", "title": "...", "authors": "...", "language": "ja", "filename": "...", "filesize": 812345, "bookfusion_id": "36835"}]} (max 50).
Each item takes the same fields as progress-PUT metadata, with the same rules (see Metadata
capture above): omitted fields never overwrite stored values, language only fills a blank, and
<service>_id fields seed exact connector matches. author is still accepted for authors, and
filesize is this endpoint's own. Use it for books whose metadata a client knows without a
progress update, e.g. books it only uploads stats for. A merged copy's metadata goes to its book.
GET /api/v1/documents lists them, newest first, 500 per page, with the same next/cursor
paging as GET /api/v1/progress.
Manual reading status: {"status": "reading" | "paused" | "finished" | "dnf"}, or
{"status": null} to go back to deriving it from progress. finished also fans out a finished
event to linked write-connectors. GET /api/v1/progress items carry the effective status
(manual, else finished at ≥ 98%, else reading) and cover_url.
Manual reading dates: {"start_date": "2026-08-11", "finished_date": "2026-08-16"} as calendar dates
(YYYY-MM-DD, no later than tomorrow). An omitted field is left alone; null clears it back to the
CrossInk / sync-history date. A start after the finish is a 400. Manual dates win over device and
derived dates in /stats/activity (which flags them with start_manual / finish_manual). Setting a
finish date also marks the book finished and fans out a finished event dated that day. Response:
{"document", "start_date", "finished_date", "status"} with dates as unix seconds at UTC midnight.
{"url": "https://..." | null, "pages": 433 | null}: cover URL and print-edition page count,
resolved from the document's title/author and cached (a miss is retried after 7 days). Covers come
from iTunes Search (ebooks), then Open Library; page counts from Open Library, then Google Books when
the server has GOOGLE_BOOKS_API_KEY, then Amazon's "Print length" via SearchAPI when it has
SEARCHAPI_KEY. GET /api/v1/progress items also carry page_count.
Manual fixes when the lookup got it wrong: {"cover_url": "https://...", "page_count": 433, "genres": ["Fantasy", "Heist"], "moods": ["Tense"], "series": "The Founders", "series_position": 1} (every
field optional). Manual values stick: lookups only fill blanks, and Hardcover's lookup leaves details set here
alone. null clears a field so it's looked up again.
The details Hardcover fills in can all be set this way:
genres(up to 10),moods(up to 12) andcontent_warnings(up to 12): lists of names of at most 64 characters. A list replaces the stored one, so to add one send the current list with it. Names are stored the way Hardcover's are (trimmed, one per name whatever the case, first letter capitalized), so "dark" set here and Hardcover's "Dark" count as one.description: up to 6000 characters.release_year: a whole number from 1 to 9999.serieswith an optionalseries_position(a number from 0 to 10000). They go together: a series set here replaces Hardcover's series and number, and next-in-series lookups stop for the book, since they follow Hardcover's series.series_positionwithoutseriesis rejected;series: nullhands both back.
language (a BCP 47 tag) can be set the same way and then stays over device syncs; null clears it so the
next device sync fills it again. Returns the stored {document, cover_url, page_count, title, author, language, genres, moods, content_warnings, description, series, series_position, release_year}.
The book's description: {"description": "...", "manual": false}. manual is true when the description was set
with PUT /info, so a client can drop the Hardcover credit. description is null when there is none.
Up to 12 covers to choose from ({"items": [{url, title, author, source, pages}]}), best matches
first, from Apple Books and Open Library. q searches a different title.
Every live clipping across all books, newest first (max 5000): {"items": [{document, id, spine, para, start_offset, chapter, text, note, created_at}]}. Read-only convenience for apps; devices
keep using the per-document delta sync.
Pages and books derived from the progress history, for readers that never send stats (stock
CrossPoint, KOReader). Every progress change is logged server-side. tz is the client's
Date#getTimezoneOffset() so days bucket locally.
{"pages_total": 1923,
"books": [{"document": "...", "started_at": 1790000000, "last_at": 1790600000, "percentage": 0.997,
"finished_at": 1790600000, "page_count": 400, "pages_read": 399,
"days_to_finish": 7}],
"days": [{"day": "2026-09-28", "pages": 56, "syncs": 4,
"books": [{"document": "...", "pages": 56, "syncs": 4, "from": 0.41, "to": 0.55}]}]}Pages are print pages (furthest percent x page count), not screen pages. A day's pages counts only
forward progress after a book's first logged sync, as a running max across devices; syncs counts
every logged sync that day, so any day with a sync is a reading day. books breaks the day down per
book for a reading timeline; from/to are the furthest position before and after that day. History from before the log
existed is backfilled from progress samples (migration 0013). started_at is CrossInk's
start_date (pinned to local noon on that day) when a device reported one, else the first sync. finished_at is CrossInk's
finished_date (pinned to local noon on that day) when a device reported one, else the first sync at
= 98%, overridden by a manual status. A CrossInk finish day with no sync still gets a
booksentry (syncs: 0) so the timeline can show it.days_to_finishcounts calendar days from start to finish, both included (a one-day read is 1); it is null when unfinished, finished only by a manual status, or already finished on the first sync with no CrossInk start date. This never estimates reading time.
Pair external services (Hardcover, Readwise, …) to the account; reading activity fans out to them
server-side. See docs/design/sync-hub.md. All under /api/v1, same auth headers. Requires the
server to have TOKEN_ENC_KEY set (credentials are encrypted at rest) — otherwise these endpoints
report encryption: "disabled" and linking returns 403.
Lists available connectors and this account's link status.
{
"encryption": "enabled",
"connectors": [
{"id": "hardcover", "name": "Hardcover", "tier": 1, "beta": false,
"carries": ["progress", "finished"], "capabilities": {"read": false, "write": true},
"credential_kind": "token", "linked": true, "status": "ok", "account": "julia",
"queue": {"pending": 0, "dead": 0}, "queue_errors": []},
{"id": "readwise", "name": "Readwise", "tier": 1, "beta": false,
"carries": ["highlight"], "capabilities": {"read": true, "write": true},
"credential_kind": "token", "linked": false, "status": null, "account": null}
]
}Link/re-link by validating and storing a credential. Body: {"credential": { ... }} — shape is
connector-specific ({"token": "..."} for Hardcover and Readwise). The server validates against the
service before storing; returns 400 if rejected. {"id": "hardcover", "linked": true, "account": "julia"} on success.
Change a linked connector's per-account options. Connectors with options list them in
GET /connectors as options ({key, label, default, choices?, gates?}) with the current
option_values. Body: a partial {"key": value}; unknown keys and bad values are ignored.
Returns {"id", "option_values"}. Turning off a toggle that gates a kind drops that kind's
pending queue rows. Hardcover: highlights (bool, default false) posts clippings as journal
quotes; privacy is private (default), follows, or public.
Unlink; wipes the stored credential, all matches, and queued work.
Lists resolved book matches (for a review UI): {"connector": "hardcover", "matches": [{"document", "external_id", "confidence", "source": "auto|manual|none", "query_used", "updated_at"}]}.
The review endpoint (GET /connectors/{id}/review) additionally carries push_note: a
per-book condition from the last successful push (e.g. Hardcover has no page count for
the book, so progress cannot sync), or null. Cleared automatically when the condition
resolves or the match changes.
It lists every synced book, newest first, 500 per page, with the same next/cursor paging as
GET /api/v1/progress.
Manually set a match (sticky — never auto-recomputed). Body {"external_id": "42"}, or
{"external_id": null} to mark "never sync this document".
Force (re)matching now; returns the resolved match or null. Preserves manual overrides.
Force-refresh the connector's server-side library list. {"count": 2}. 400 for
connectors without a refreshable library.
Verify an externally-supplied book id against the user's account at the service.
Body {"external_id": "…"}. {"found": true, "book": {"externalId", "title", "author", "edition"}} or {"found": false}. 400 for connectors without lookup.
Matching is server-side from the document's title/author (the EPUB metadata the firmware sends —
so connectors need "Send Metadata" on). Fan-out is automatic: a progress PUT enqueues a
progress/finished event to write-connectors that carry it; a clippings PUT enqueues highlight events
to highlight-connectors (Readwise, BookOrbit, and Hardcover when its highlights option is on). A background worker delivers them with retry/backoff.
Fan-in (read connectors: Audiobookshelf, Jellyfin, BookOrbit, Readwise Reader, BookFusion) is pulled on a
background interval for library-wide providers, and on-demand — when a device asks for progress on a
matched book — for per-book providers. Jellyfin and Spotify also run an hourly per-match pull for users
with matched books.
BookOrbit also pulls highlights on that refresh: each one is placed by its CFI in BookOrbit's copy of
the EPUB and stored as a clipping (chapter codepoint offsets, firmware-style id), skipped when the same
quote is already a clipping on the book, live or deleted.
For outgoing BookOrbit clippings, older records without text offsets are located by their full quote in BookOrbit's EPUB. Whitespace, soft hyphens, and straight/curly quotation-mark differences are ignored; the match must be unique across the whole book. Missing or ambiguous quotes fail explicitly rather than guessing a location. When the formatting comparison finds nothing, long quotes (at least 40 characters after normalization) can match with case and punctuation differences ignored, again requiring the entire quote to be unique. Short selections remain strict. Clippings with offsets continue to use their exact saved positions and text checks.
The connector list includes queue_errors: [{kind, message, count}] for linked services: up to
three recent grouped failures for that user's service, including pending retries and permanently
failed items. Completed items no longer contribute errors. Settings shows waiting/failed counts
and these messages, refreshing every five seconds while work is pending. The "Sync now" response's
queued value counts export items (progress and individual clippings), not distinct books or
successful deliveries. "Sync now" also requeues failed items for another attempt. For
Audiobookshelf it first pulls every matched book's position (the response adds pulled); the
server also pulls those positions every 15 minutes, so listening shows up without opening the book
on a reader.
Books in progress (status unset or reading) with no new position for the account's
auto-pause days become paused with pause_reason: "auto"; any new position un-pauses them.
GET /api/v1/account/auto-pause returns { "days": 30 } (the default). PUT with
{ "days": 0-365 } sets it (0 = never, null = back to the default). Changing it applies at
once: auto-paused books the new window no longer covers go back to reading, and books now past it
are paused. Manual pauses are left alone.
GET /api/v1/profile returns { "slug": null } while off. PUT /api/v1/profile with
{ "enabled": true } turns it on and returns the random slug (kept while on; off then
on makes a new one). The public page is GET /u/{slug}: no auth, HTML with reading
stats, the books being read and the finished ones, embeddable in an iframe. 404 when off.
Unauthenticated. {"status": "ok", "version": "0.1.0"}.
| What | Limit |
|---|---|
| Batch items per PUT (bookmarks, clippings, documents) | 50 |
| Batch items per PUT (per-book stats) | 20 |
| List page size | default 50, max 100 |
progress string |
4096 bytes |
position anchor / xpath |
48 / 120 bytes |
clipping text / note / chapter |
2048 / 4096 bytes / 64 chars |
bookmark xpath / summary |
512 / 256 chars |
| username / password key | 64 / 128 chars |