Skip to content
Library Manager Cloud
Language
Theme
Sign in Start for free

Developer documentation

Library Manager mobile API

OAuth account connection, local book transfers and personal reading synchronization: the complete Library Manager mobile API reference.

This API lets a mobile application access the account's spaces, copy books between the service and its local library, and synchronize personal reading progress in both directions. Reading requires a local file whose size and SHA-256 have been verified. Mobile endpoints provide no chapter reader or remote HTML publication.

The reference origin is https://library-manager.com. Business endpoints use /api/v1/mobile; OAuth endpoints use /api/mobile/oauth. All examples contain synthetic identifiers. Keep tokens and signed transfer URLs private: never print them in logs or crash reports.

1. Complete application flow

  1. Generate a stable installation UUID and store it with the account's local library.
  2. Generate a cryptographically random PKCE verifier, its S256 challenge, and a random state.
  3. Open the system browser at the authorization endpoint.
  4. The user signs in with their account email and password on the service, verifies their email when required, and approves the application.
  5. Compare the returned state exactly with the stored state, then exchange the authorization code.
  6. List accessible spaces, browse a selected space, and select a ready book version.
  7. Download into a private temporary file. Verify its bytes, size and SHA-256, then publish it atomically into the phone's local library.
  8. Confirm that local copy to the API. Open the book using the local reader.
  9. Pull personal progress for the same version and retain the server revision.
  10. Record new positions in a durable local queue. On reconnection, submit them in order. A concurrent update requires an explicit user choice.

2. Account sign-in and OAuth

The official public client is library-manager-mobile, with the exact registered redirect URI https://library-manager.com/mobile/callback. No client secret belongs in the mobile binary. During mobile app development, associate this HTTPS URL with the signed binary using iOS Universal Links and Android App Links. A callback webpage alone does not prove native association.

Email and password are entered on the service's login page in the system browser. The OAuth password grant is unsupported. PKCE S256 is mandatory. Plain challenges, arbitrary redirects, wildcards, fragment-bearing redirects and duplicate request fields are rejected.

Generate PKCE and state

js
import { createHash, randomBytes } from 'node:crypto';
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url');
const state = randomBytes(32).toString('base64url');
const parameters = new URLSearchParams({
  response_type: 'code',
  client_id: 'library-manager-mobile',
  redirect_uri: 'https://library-manager.com/mobile/callback',
  code_challenge: challenge,
  code_challenge_method: 'S256',
  state,
  scope: 'library:read files:read files:write progress:read progress:write'
});
const authorizationUrl = 'https://library-manager.com/api/mobile/oauth/authorize?' + parameters;
// Store verifier and state privately until the callback.
// Open authorizationUrl in the system browser.

This Node.js example must be adapted to native cryptographic primitives on the phone. The verifier contains 43–128 ASCII PKCE characters. The challenge is its SHA-256 in unpadded base64url. State contains 16–512 characters and must match the original request exactly. Generate new values for a new authorization attempt.

Scopes and space permissions

Scope · Capability
ScopeCapability
library:readAccessible spaces, book catalog and details
files:readDownloads and local copy confirmations
files:writeImports from the phone into a space
progress:readRead personal progress
progress:writeUpdate personal progress

The authenticated identity endpoint is available to a valid Bearer grant. A scope never replaces space permissions. Readers can browse, download and write their own progress; they cannot import. Owners and editors can import when the space and its quotas permit it. The server checks current membership and rights on every call.

Without progress:read, catalog and details return readingPercent: null and progress: null. The readingPercentMin and readingPercentMax filters and readingPercent sort are rejected to prevent a catalog-based progress oracle. Public OAuth metadata is available at /.well-known/oauth-authorization-server and /api/mobile/oauth/metadata.

Authorize the app

http
GET /api/mobile/oauth/authorize?response_type=code&client_id=library-manager-mobile&redirect_uri=https%3A%2F%2Flibrary-manager.com%2Fmobile%2Fcallback&code_challenge=CHALLENGE_BASE64URL&code_challenge_method=S256&state=RANDOM_STATE&scope=library%3Aread%20files%3Aread%20files%3Awrite%20progress%3Aread%20progress%3Awrite

The service redirects to a consent page. Creating a grant requires a verified account, current accepted terms and no scheduled account deletion. Consent is an explicit browser POST protected by the same-origin and CSRF checks. GET requests issue no authorization code.

On approval, the registered redirect receives code and state. The code expires after two minutes, can be exchanged once, and is bound to the client, redirect, challenge and authorizing session. Denial returns an OAuth error and the original state only to a validated registered redirect.

Receive the official callback

The official URI remains https://library-manager.com/mobile/callback. A future signed mobile app may handle it through its configured Universal Links or App Links association. When no native handler consumes the link, GET /mobile/callback returns the callback result as JSON instead of redirecting to the documentation.

An approval returns HTTP 200 with exactly code and state. These values are synthetic examples; the code has not been issued and cannot authorize an exchange:

http
GET /mobile/callback?code=lmc_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG&state=synthetic-state-0123456789
json
{
  "code": "lmc_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
  "state": "synthetic-state-0123456789"
}

A denial returns HTTP 200 with exactly error: "access_denied" and the original state:

json
{
  "error": "access_denied",
  "state": "synthetic-state-0123456789"
}

The query must contain exactly code plus state, or error=access_denied plus state. Unknown, missing or duplicate parameters, mixed success/error fields, unsupported error values and invalid bounds return HTTP 400 with only { "error": "invalid_request" }. The code must contain lmc_ followed by 43 base64url characters. State must contain 16–512 characters with no control characters; it is returned unchanged.

Compare the returned state exactly with the state stored for the pending authorization request before accepting either outcome. Exchange an approved code only with that request's original PKCE verifier. The callback validates syntax and transports values; its response does not authenticate state or assert a user identity. It creates no cookies, issues no access or refresh tokens and performs no token exchange.

Both success and error responses use Cache-Control: private, no-store, Referrer-Policy: no-referrer, X-Robots-Tag: noindex, nofollow and X-Content-Type-Options: nosniff. They have no Location header. Do not retain callback URLs or their contents in logs, analytics or crash reports. The HTTP fallback does not prove that a signed phone application has a working native link association.

Exchange the code

sh
curl --fail-with-body --silent --show-error \
  --request POST 'https://library-manager.com/api/mobile/oauth/token' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=library-manager-mobile' \
  --data-urlencode 'redirect_uri=https://library-manager.com/mobile/callback' \
  --data-urlencode "code=$OAUTH_CODE" \
  --data-urlencode "code_verifier=$PKCE_VERIFIER"

Private response:

json
{
  "access_token": "lma_INVALID_SYNTHETIC_EXAMPLE",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": "lmr_INVALID_SYNTHETIC_EXAMPLE",
  "scope": "library:read files:read files:write progress:read progress:write"
}

The example tokens are deliberately invalid. Real tokens contain a prefix and 43 random base64url characters. They are opaque tokens, not JWTs. The server stores hashes rather than plaintext mobile credentials.

Refresh tokens

sh
curl --fail-with-body --silent --show-error \
  --request POST 'https://library-manager.com/api/mobile/oauth/token' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode 'client_id=library-manager-mobile' \
  --data-urlencode "refresh_token=$REFRESH_TOKEN"

Each refresh replaces both tokens. Store the new pair atomically before resuming requests. Only one refresh operation may run at a time per installation. Reusing a consumed refresh token revokes the whole family, including the newly issued access token. If the refresh response is lost, request a new sign-in rather than blindly retrying the old refresh token.

The first rotation is immediate; later rotations require at least five seconds between them. A family retains at most 4,096 refresh hashes, including its initial token, allowing 4,095 rotations. Exceeding a throttle returns HTTP 429 temporarily_unavailable without consuming the valid refresh token. The absolute family budget requires a new authorization. At most twenty active grants and twenty pending codes are allowed per account. New authorization requests clean terminal families and consumed codes while preserving the history needed to detect reuse in live families.

Access lasts at most ten minutes. A refresh family lasts at most seven days and never exceeds the authorizing session's expiry. Refresh does not extend that absolute deadline. Password reset, session revocation or session deletion invalidates the mobile grants attached to that session.

Revoke a grant

sh
curl --fail-with-body --silent --show-error \
  --request POST 'https://library-manager.com/api/mobile/oauth/revoke' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'client_id=library-manager-mobile' \
  --data-urlencode "token=$REFRESH_TOKEN" \
  --data-urlencode 'token_type_hint=refresh_token'

Revocation is idempotent. Unknown and already revoked tokens receive the same successful outcome. Then remove tokens from secure device storage. Local-file retention is a mobile product decision; signing out does not itself delete the cloud library.

3. HTTP conventions

All business endpoints require Authorization: Bearer <access_token>. Cookies alone never authenticate a mobile endpoint. An invalid Bearer remains invalid even when the request also carries a valid browser cookie. Mobile tokens are accepted only within the mobile namespace.

sh
curl --fail-with-body --silent --show-error \
  'https://library-manager.com/api/v1/mobile/spaces' \
  --header "Authorization: Bearer $ACCESS_TOKEN"

Business writes use application/json. Bodies are limited to 65,536 actual bytes, even without Content-Length. OAuth forms have a smaller limit. Unknown or duplicate fields and invalid UUIDs are rejected; UUIDs are normalized to lowercase. OAuth and business responses use private, non-cacheable headers.

Support requests may include X-Request-Id, but must exclude tokens, signed URLs and book contents. A signed URL is a temporary capability, not a durable file identity. Use the exact method and headers the server supplies.

4. Identity and spaces

http
GET /api/v1/mobile/me
Authorization: Bearer ACCESS_TOKEN

The response identifies the user, grant, client, scopes and local-copy reading mode. Separate the library, tokens and reading queue by account. Do not silently reuse another account's data after switching users.

http
GET /api/v1/mobile/spaces
Authorization: Bearer ACCESS_TOKEN

The response is an array of accessible spaces with id, name, slug, role, locale, plan, createdAt. Use UUIDs as identifiers. Refresh this list on resume and after an authorization failure. Previously cached membership grants no permanent access.

5. Catalog and versions

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books?pageSize=50
Authorization: Bearer ACCESS_TOKEN

Catalog pages contain items, nextCursor and total. Send returned cursors unchanged. The existing library query contract validates filtering, sorting and page size. A paginated traversal is not an atomic snapshot: absence from a page or from a traversal must never trigger automatic local-file deletion.

Search the selected space

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/search?q=night%20voyage&pageSize=25
Authorization: Bearer ACCESS_TOKEN

This endpoint searches only the selected space through the same authorized catalog service. It requires a valid Bearer and library:read; a cookie never substitutes for the token. Pass exactly one q, containing 1–512 characters after trimming. A missing, duplicated, empty, whitespace-only or oversized keyword returns HTTP 422. The search parameter is forbidden on this dedicated route, including when q is also present. Unknown fields, duplicated scalar fields and invalid filter ranges retain the catalog's strict validation.

The search index contains exactly six bibliographic metadata fields: title, subtitle, authors, series, genres and tags. It does not index filenames, publisher, description, translators, ISBN/ASIN, book contents or reading positions. Matching uses whole text-search lexemes, ignores case and accents, and performs no stemming, morphological expansion, fuzzy matching, prefix matching or substring matching. Use the following query syntax:

Query · Meaning
QueryMeaning
night voyageBoth terms must match: implicit AND.
night OR voyageEither term may match.
"night voyage"Phrase match in the indexed text.
voyage -abridgedMatch voyage while excluding abridged.

All catalog filters, pagination and ordering remain available:

Group · Parameters
GroupParameters
Bibliographic facetsauthors, series, genres, tags, languages, formats, editions, publishers, completeness
Bibliographic rangesseriesIndexMin, seriesIndexMax, publicationDateFrom, publicationDateTo
Status and private statestatuses, metadataStatuses, favorite, ratingMin, ratingMax, readingPercentMin, readingPercentMax
Files and device presencesizeBytesMin, sizeBytesMax, optimized, presentOnDeviceIds
DatescreatedFrom, createdTo, updatedFrom, updatedTo
Page and orderpageSize, cursor, sort, direction, view

pageSize is an integer from 1 to 100, defaulting to 50. Default ordering is sort=title&direction=asc; supported sorts are title, author, series, seriesIndex, publicationDate, createdAt, updatedAt, totalBytes, rating and readingPercent. Direction is asc or desc, and view is grid or table. Array filters accept their catalog representation, including JSON arrays; booleans use true or false. Minimum bounds cannot exceed corresponding maximum bounds. Date and timestamp filters retain the catalog's date formats.

Filtering by reading percentage or sorting by readingPercent additionally requires progress:read; otherwise the server returns HTTP 403. Without that scope, readingPercent remains null in returned summaries. No search parameter grants access to another space or another person's progress.

sh
curl --fail-with-body --silent --show-error --get \
  'https://library-manager.com/api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/search' \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --data-urlencode 'q="night voyage" -abridged' \
  --data-urlencode 'pageSize=25' \
  --data-urlencode 'sort=title' \
  --data-urlencode 'direction=asc' \
  --data-urlencode 'formats=["epub","txt"]'

For another page, repeat the same q, filters, sort and direction, adding the returned cursor unchanged. Discard the old cursor and start a new traversal whenever any search or filter setting changes. Never reuse a cursor from another account or space. A client can retain the complete query when constructing the next request:

js
async function searchMobileBooks(accessToken, spaceId, query, cursor) {
  const parameters = new URLSearchParams(query);
  parameters.delete('cursor');
  if (cursor) parameters.set('cursor', cursor);
  const response = await fetch(
    `https://library-manager.com/api/v1/mobile/spaces/${spaceId}/books/search?${parameters}`,
    { headers: { Authorization: `Bearer ${accessToken}` }, credentials: 'omit', cache: 'no-store' }
  );
  if (!response.ok) throw new Error('searchUnavailable');
  return response.json();
}

const query = new URLSearchParams({ q: 'night voyage', pageSize: '25', sort: 'title', direction: 'asc' });
// Keep this complete query for subsequent pages; replace it and reset the cursor when settings change.

HTTP 200 returns items, nextCursor containing an opaque cursor or null, and total: null. An empty search result is a successful page such as { "items": [], "nextCursor": null, "total": null }; it must never trigger deletion of a local book. Responses remain private and non-cacheable. The existing list endpoint also continues to support /books?search=keyword with the same search behavior; the dedicated /books/search endpoint uses the required name q.

Get book details

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/22222222-2222-4222-8222-222222222222
Authorization: Bearer ACCESS_TOKEN

Details include bibliographic metadata, ready versions and personal state. Mobile projections exclude producer identities, internal processing parameters, review proposals and evidence. Each immutable version has its own format, filename, byte size, SHA-256 and identity. Conversions and optimizations create different versions rather than changing existing version bytes.

Display a book cover

Use a version ID from the book detail response's ready versions, together with the space and book IDs. A mobile cover is a catalog image: it requires library:read and a valid Authorization: Bearer header, without requiring files:read or a confirmed local book copy. Cookies alone never authenticate this route. Reading the book still requires the verified local file described in section 6.

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/22222222-2222-4222-8222-222222222222/versions/33333333-3333-4333-8333-333333333333/cover?profile=cover_v1
Authorization: Bearer ACCESS_TOKEN

profile defaults to cover_v1 when absent; no other profile is supported. Unknown or duplicate query fields, an invalid profile or invalid UUIDs return HTTP 422. The route returns the image bytes directly, never an object-storage URL or internal storage reference. HTTP 200 uses Content-Type: image/webp, an exact Content-Length and an ETag of the form "sha256-<64 lowercase hexadecimal characters>". The published thumbnail is at most 512 KiB and has width at most 640 pixels and height at most 960 pixels.

Every response uses Cache-Control: private, no-store, Vary: Authorization, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer. The service freshly checks the account's space rights, logical book and ready physical source before answering HEAD or evaluating If-None-Match; its internal byte cache never bypasses this authorization. GET authorizes again before reading cached or hash-verified storage bytes.

HEAD returns the same image headers and metadata length without a body. A matching If-None-Match returns HTTP 304 without a body only after those fresh checks. Strong tags, weak tags such as W/"sha256-...", comma-separated lists and * are supported. A header longer than 8,192 characters is ignored and the normal response is produced.

The following commands use synthetic IDs. Keep the environment token private and save the image into private application storage:

sh
COVER_URL='https://library-manager.com/api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/22222222-2222-4222-8222-222222222222/versions/33333333-3333-4333-8333-333333333333/cover?profile=cover_v1'
curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --output 'private-cover.webp' "$COVER_URL"
curl --fail-with-body --silent --show-error --head \
  --header "Authorization: Bearer $ACCESS_TOKEN" "$COVER_URL"
curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --header 'If-None-Match: "sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' \
  --output 'private-cover-response.bin' "$COVER_URL"

Replace the synthetic conditional tag with the ETag obtained for this exact account, space, book and version. A 304 response has no image bytes: use it only when the matching private image is already available. Do not overwrite that cached image with the empty conditional response.

For a client that deliberately keeps a bounded private cover cache, send the Bearer explicitly rather than relying on an image element's cookies. This JavaScript example uses a private cache port and an account-generation check supplied by the app:

js
async function fetchMobileCover(context, privateCache, isCurrentAccount) {
  const { accountId, accessToken, spaceId, bookId, versionId } = context;
  const key = JSON.stringify([accountId, spaceId, bookId, versionId]);
  const previous = privateCache.get(key);
  const headers = { Authorization: `Bearer ${accessToken}` };
  if (previous?.etag) headers['If-None-Match'] = previous.etag;
  const path = `/api/v1/mobile/spaces/${spaceId}/books/${bookId}/versions/${versionId}/cover`;
  const response = await fetch('https://library-manager.com' + path, {
    headers, credentials: 'omit', cache: 'no-store', redirect: 'error'
  });
  if (!isCurrentAccount()) throw new Error('accountChanged');
  if (response.status === 401 || response.status === 403) {
    privateCache.delete(key);
    throw new Error('coverAccessDenied');
  }
  if (response.status === 304) {
    if (!previous) throw new Error('missingPrivateCover');
    return previous.blob;
  }
  if (!response.ok) {
    privateCache.delete(key);
    throw new Error('coverUnavailable');
  }
  if (response.headers.get('Content-Type') !== 'image/webp') {
    throw new Error('invalidCover');
  }
  const blob = await response.blob();
  if (!isCurrentAccount()) throw new Error('accountChanged');
  if (blob.size < 1 || blob.size > 512 * 1024) throw new Error('invalidCover');
  privateCache.set(key, { blob, etag: response.headers.get('ETag') });
  return blob;
}

The example's context must come from the authenticated account and validated IDs. Implement the cache with explicit byte and entry limits in private app storage. On account switching or sign-out, cancel pending requests, invalidate the account generation and clear that account's cover cache; discard late responses. Never reuse a cover after a 401 or 403, and never show a previous account's cover while authorization for the new account is pending. HTTP no-store prevents automatic response caching; any app-managed cache requires this explicit ownership and lifecycle.

An absent cover, extraction still pending, or an unavailable/non-ready source returns HTTP 404. Display a placeholder and optionally retry with bounded backoff; this is not a request to delete the local book. GET does not start cover generation on demand. Non-image errors have the usual safe JSON error envelope, while HEAD errors have no body: 401 for missing/invalid Bearer (with WWW-Authenticate: Bearer), 403 for insufficient scope or rights, 404 for unavailable cover/source, 422 for validation or integrity failure, 429 for bounded read concurrency and 503 for storage/configuration unavailability. POST, PUT, DELETE, PATCH and OPTIONS return 405 with Allow: GET, HEAD after Bearer and scope checks. No arbitrary image URL, internal storage reference or producer identity is exposed.

6. Copy cloud files to the phone

Obtain a download capability

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/22222222-2222-4222-8222-222222222222/versions/33333333-3333-4333-8333-333333333333/download
Authorization: Bearer ACCESS_TOKEN

The descriptor contains bookId, versionId, format, filename, mediaType, sizeBytes, sha256, download.url, download.expiresAt, and readingMode: "local_copy". The signed download refers to the exact verified storage version and expires after five minutes. Send the account's Bearer only to Library Manager, never to object storage. Obtain a new capability if the previous URL expires.

js
async function downloadToLocal(api, files, spaceId, bookId, versionId) {
  const descriptor = await api.getDownload(spaceId, bookId, versionId);
  const temporary = await files.createPrivateTemporary();
  try {
    await files.fetchToFile(descriptor.download.url, temporary);
    const identity = await files.hashAndSize(temporary);
    if (identity.sha256 !== descriptor.sha256 || identity.sizeBytes !== descriptor.sizeBytes) {
      throw new Error('integrityMismatch');
    }
    const local = await files.publishAtomically(temporary, descriptor.versionId);
    await files.recordCopy({ ...descriptor, localPath: local });
    await api.confirmLocalCopy(spaceId, bookId, {
      installationId: await files.installationId(),
      versionId: descriptor.versionId,
      sha256: descriptor.sha256,
      sizeBytes: descriptor.sizeBytes
    });
    return local;
  } catch (error) {
    await files.removeTemporary(temporary);
    throw error;
  }
}

The api and files objects are example ports to implement with mobile platform primitives. This is protocol pseudocode, not a shipped native reader. File publication and local metadata must recover after a crash. Write a durable intent first, then reconcile the file and its database record on restart.

Confirm a local copy

http
PUT /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/22222222-2222-4222-8222-222222222222/local-copy
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json

{
  "installationId": "44444444-4444-4444-8444-444444444444",
  "versionId": "33333333-3333-4333-8333-333333333333",
  "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "sizeBytes": 123456
}

The declaration must match the ready version exactly. Repeating the same confirmation is idempotent. The server refuses progress without the installation's matching confirmation, but cannot prove that a physical phone stores those bytes. The mobile app must verify the file before opening it and offer a new download if it is missing or corrupt.

Confirmations are bounded to twenty installation identities and 100,000 copies per account and space, enforced atomically by PostgreSQL under concurrency. A budget failure returns HTTP 429 without a retry delay: forget unused copies before resuming. Existing confirmations remain idempotently replayable at the limit.

List and forget local copies

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/local-copies?installationId=44444444-4444-4444-8444-444444444444&limit=50

The page contains only the account's confirmations for this installation in this space. Its cursor is returned by the API; it is not a scan of the physical phone. Limits are 1–100, default 50.

http
DELETE /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/local-copies/33333333-3333-4333-8333-333333333333?installationId=44444444-4444-4444-8444-444444444444

After deleting the local file and updating the local catalog, forget its confirmation. This idempotent DELETE removes no cloud book, reading progress or other installation's file.

7. Copy phone files to the cloud

Imports require files:write and an owner or editor role. They reuse the existing durable storage pipeline: quotas, staging upload, byte verification, asynchronous processing, preservation of originals and deduplication limited to the selected space. Supported formats are epub, mobi, azw3, txt, html, fb2.

Prepare the import

http
POST /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/imports
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json

{
  "filename": "example-book.epub",
  "format": "epub",
  "sizeBytes": 123456,
  "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "idempotencyKey": "88888888-8888-4888-8888-888888888888"
}

Filename has at most 255 characters and no path separators. Size is 1–536,870,912 bytes, with plan quotas potentially stricter. SHA-256 contains 64 lowercase hexadecimal characters; it is optional at preparation, required at completion. Persist the UUID idempotency key with the local job. Ordinary mobile imports omit optional deviceId.

The response contains session and upload. Send bytes to upload.url using upload.method and upload.headers exactly. Do not attach the Library Manager Bearer to object storage or alter signed headers.

Complete and poll

http
POST /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/imports/55555555-5555-4555-8555-555555555555/complete
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json

{
  "expectedRevision": 1,
  "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}

Use the received session revision. The server reads the stored bytes, verifies their identity and queues processing. Upload success alone does not make a book ready.

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/imports/55555555-5555-4555-8555-555555555555
Authorization: Bearer ACCESS_TOKEN

Poll with bounded backoff until ready or duplicate. Keep the local file if the operation fails or requires review. Never automatically restart a paid operation or conversion after an ambiguous outcome. Confirm the published local copy only if its final version matches local bytes; otherwise download and verify that version first.

8. Bidirectional reading progress

Bookmarks and passage notes

Bookmarks and notes made in the web reader are shared with the app, under the progress scopes: progress:read to read, progress:write to write. They are private: no other member of the space ever sees them, and a Reader can annotate what they read.

http
GET    /api/v1/mobile/spaces/{spaceId}/books/{bookId}/annotations
POST   /api/v1/mobile/spaces/{spaceId}/books/{bookId}/annotations
PATCH  /api/v1/mobile/spaces/{spaceId}/books/{bookId}/annotations/{annotationId}
DELETE /api/v1/mobile/spaces/{spaceId}/books/{bookId}/annotations/{annotationId}?expectedRevision=1
bash
curl -sS -X POST "https://library-manager.com/api/v1/mobile/spaces/$SPACE_ID/books/$BOOK_ID/annotations" \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Content-Type: application/json' \
  -d '{"versionId":"33333333-3333-4333-8333-333333333333","chapterId":"chapter-1","position":"/body/DocFragment[1]/body/p[3]/text().42","quote":"Synthetic passage.","note":null}'
  • A bookmark has no note; a passage note has one (4,000 characters at most). quote carries the passage or a short extract (600 characters at most, whitespace collapsed).
  • position is a KOReader XPointer (exact), or ratio:<0..1> within the chapter chapterId for a file with no KOReader anchor. No EPUB CFI is accepted.
  • Each item stays tied to its versionId. The list returns every version of the logical book: show "from another version" when the open file differs, without moving the item.
  • Update and delete name expectedRevision (CAS). On HTTP 409, reread the list and never force a retry. At most 1,000 bookmarks and notes per book and per account (HTTP 413).
  • They appear in the personal data export and are erased with the account.

Pull progress

http
GET /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/22222222-2222-4222-8222-222222222222/progress?versionId=33333333-3333-4333-8333-333333333333
Authorization: Bearer ACCESS_TOKEN

Response is personal progress for the selected version or null. Fields are bookId, versionId, progress, percentage, revision, updatedAt. Version anchors preserve positions across version switches, but a position in different bytes is not automatically an exact location in the new edition. No progress means revision zero for the first write.

Shared KOReader, browser and mobile location format

Reading APIs use only KOReader `progress` and `percentage`. progress is a CREngine text XPointer such as /body/DocFragment[2]/body/p[3]/text()[1].42, or null when the exact location is unavailable. Offsets count Unicode code points (UTF-32), not JavaScript UTF-16 units. percentage is a fraction from 0 to 1. Legacy cfi, chapterId, percent, EPUB CFI and scroll JSON are rejected by public writes. Historical data is retained, with its reading fraction, but legacy coordinates are no longer exposed as active locations.

Locations belong to the exact file version and SHA-256. Browser/mobile writes publish the same XPointer and fraction to linked KOReader document identifiers and a known unambiguous content digest, transactionally with progress. Mobile GET considers the latest linked KOReader location for the selected version. The browser resolves source provenance preserved before sanitization; unresolvable locations fall back explicitly to the percentage, without another coordinate format. CAS revisions and idempotent mobile receipts remain transport/concurrency protections, not a different location system.

For continuous sync, debounce changes, persist each operation, await its receipt, and refresh on foreground/open. Handle conflicts explicitly. Routes, scopes and confirmed local-copy requirements remain; mobile clients must adopt this KOReader payload and actually produce/consume XPointers. The previous CFI payload is no longer accepted.

Kosync also reads durable browser/mobile positions after late file indexing, even without another save or a pre-existing document alias. This projection remains personal and refuses ambiguous version matches.

When upgrading a client with pending CFI/JSON operations, retain its local state, pull current progress and reconcile before creating a new KOReader operation. Never change an existing operation body or reuse its UUID for a different payload. Legacy bodies return validationFailed; do not retry them indefinitely.

Submit a durable operation

http
PUT /api/v1/mobile/spaces/11111111-1111-4111-8111-111111111111/books/22222222-2222-4222-8222-222222222222/progress
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json

{
  "installationId": "44444444-4444-4444-8444-444444444444",
  "operationId": "66666666-6666-4666-8666-666666666666",
  "expectedRevision": 0,
  "versionId": "33333333-3333-4333-8333-333333333333",
  "progress": "/body/DocFragment[1]/body/p[1]/text()[1].0",
  "percentage": 0.125
}

Persist the operation UUID and entire body before sending. Retry a lost response with exactly the same body and UUID. The server returns the stored receipt without incrementing the revision again. Reusing an operation UUID for different contents causes a conflict. New positions use new operations. Percentage is a 0–1 fraction; progress is a KOReader text XPointer or null when unavailable, never a fabricated position.

The installation must have confirmed this exact version. Progress locking and CAS apply to the user's logical book, including merged aliases. Personal reading never changes shared bibliographic metadata.

Resolve conflicts explicitly

  1. Keep local progress and its failed operation.
  2. On HTTP 409 revisionConflict, fetch current server progress.
  3. Show both positions, versions and dates to the user.
  4. If they choose server progress, apply it only to a matching local version and close the failed local operation.
  5. If they choose local progress, create a new operation UUID using the freshly read server revision.
  6. A further conflict requires another choice; do not retry a force-overwrite loop.

Intentional backward reading is valid. Phone timestamps and the highest percentage never silently decide the winner.

Offline queue and replay window

Store files, account/space/book/version identities, installation ID and operations in a transactional local database. Submit the next position only after persisting the previous receipt. Use one network queue consumer. The local reader remains available offline and retains its latest position durably.

Receipts remain replayable for thirty days, with at most 10,000 per account and space. Reconcile an older queue explicitly against current server progress before issuing new operations. Expired receipts are not guaranteed to be available.

9. Errors, retries and revoked rights

OAuth errors use the protocol format:

json
{"error":"invalid_grant"}

Business errors use stable application codes:

json
{
  "error": {
    "code": "revisionConflict",
    "messageKey": "errors.revisionConflict",
    "requestId": "77777777-7777-4777-8777-777777777777",
    "details": {"expectedRevision": 0, "currentRevision": 1}
  }
}
HTTP / code · Client action
HTTP / codeClient action
401 unauthenticated or OAuth invalid_grantRefresh once when appropriate, otherwise sign in again
403 forbiddenCheck scope and current membership
403 termsAcceptanceRequiredOpen the service and accept current terms, then resume explicitly
423 accountDeletionScheduledShow the account freeze and link to account management
404 notFoundReconcile catalog and rights; never delete local files automatically
409 revisionConflictPreserve the queue and ask for an explicit conflict choice
409 quotaExceededShow the plan limit and keep the local file
413 requestTooLargeCorrect the request; no identical retry
422 validationFailed / integrityMismatchCorrect the body or download verified bytes again
429Respect the returned retry delay, then back off with jitter
503Retry only operations whose replay is safe, with bounded backoff

Accounts with outdated terms retain data-access reads while ordinary writes are frozen. Scheduled deletion freezes business writes. Local confirmations and receipts are personal, included in data exports, and erased with the relevant member or space data.

10. Mobile application security

Use Keychain or Keystore for tokens, separated by account. Verifier and state belong only to the pending authorization request. Keep temporary files and the local library in private application storage. Logs retain only safe error codes and request IDs.

Never open a storage URL directly in the reader. Never send Library Manager cookies to object storage. Check OAuth redirects and state. Signed URLs are temporary capabilities and must not become public history entries or durable file identifiers.

Server authorization is independent of the client. Submitted UUIDs name resources; the server determines identity, roles and scopes itself. A local-copy declaration does not replace authorization.

11. Configuration and verification boundaries

The official client and redirect are registered by default. Operators may configure a bounded explicit registry with MOBILE_OAUTH_CLIENTS_JSON. Each entry contains id, name, redirectUris, scopes. URI comparison is exact. Mobile requests cannot register arbitrary clients.

This guide documents the backend API and the mobile application's required behavior. HTTP tests and copy confirmations do not prove physical-device reading. Native app identities, signed URL associations and a working local reader must be supplied and verified with the future mobile binary.