Source docs/api.md · 1de96aa

Automation API

This page is for developers who want a script or another server to send books to Libris and get the translation back, with no step in the web interface. You send an EPUB, some TXT or DOCX chapters or a JSON document; Libris runs the whole pipeline on its own; you poll the status (or receive a webhook) and download the result with its completion report.

The automation API lives under /api/v1 and is separate from the API used by the web interface:

  • it only accepts API tokens (Authorization: Bearer …). The interface’s session cookie does not open /api/v1, and a token does not open the interface’s routes;
  • work is asynchronous. A request is saved in the database before the 202 Accepted answer, the pipeline runs in the worker, and no HTTP connection stays open during a translation;
  • a request always ends: completed, completed_with_residuals, failed (with the reason) or cancelled. It never stays running forever.

Reader-facing cultural notes in exported books

Reviewed notes belong to accepted glossary terms of a volume. EPUB results use EPUB 3 footnotes with backlinks; text results use numbered chapter endnotes. Source-retained passages are never annotated. In a partial Word export, first means the first occurrence within the selected excerpt, without loading chapters outside that range. Text and reading-EPUB excerpts retain the volume-wide occurrence. The bilingual EPUB annotates only its translated side. JSON chapter text and ZIP chapter files contain readable note numbers and explanations, never the temporary annotation markers used by the exporter. An original EPUB 2 is upgraded to EPUB 3 only when a note is inserted, preserving archive resources and passing the usual EPUBCheck gate. Existing exports are unchanged when no note is published.

These editorial notes are managed by authenticated editors in Glossary › Translator’s notes, not by an unauthenticated export or a model response. The session API exposes GET /api/projects/{pid}/reader-notes and PUT /api/projects/{pid}/glossary/{gid}/reader-note; the latter requires reader_note, previous_note, and note_placement (first, each, never), and accepts previous_placement and proposal_job for conflict checking. A stale edit/proposal returns 409. POST /api/projects/{pid}/jobs accepts the paid, asynchronous translator_notes operation; proposals remain in its result until explicitly reviewed. These are session routes: API tokens do not open them.

At a glance

Method and pathScopePurpose
POST /api/v1/translation-requestscontent:write (+ pipeline:start to translate)Send an EPUB, TXT or DOCX chapters or a JSON document
GET /api/v1/translation-requests/{id}jobs:readStatus, progress and report (?wait= to long-poll)
POST /api/v1/translation-requests/{id}/pausejobs:controlPause the request’s job
POST /api/v1/translation-requests/{id}/resumejobs:controlResume it
POST /api/v1/translation-requests/{id}/canceljobs:controlCancel it
GET /api/v1/translation-requests/{id}/resultresults:readDownload the result: ?format= (EPUB, bilingual EPUB with ?layout=, JSON, TXT or ZIP), ?scope= (the request’s chapters, only the new ones or the whole volume), ?partial= and ?wait= (see Get the result)
GET /api/v1/providerscontent:writeList the providers a request may use
GET /api/v1/seriesseries:readList your series
GET /api/v1/series/{id}series:readOne series and its volumes
POST /api/v1/series/{id}/jobs/{action}jobs:controlSuspend/resume/cancel a series with a per-volume report; see Persistent suspension of a series
GET /api/v1/glossariesseries:readList your shared glossaries
POST /api/v1/glossariescontent:writeCreate a shared glossary
GET /api/v1/glossaries/{id}series:readOne shared glossary and its terms
GET /api/v1/glossaries/{id}/export/{format}series:readDownload it as JSON, CSV or TBX
POST /api/v1/glossaries/{id}/importcontent:writeImport a JSON, CSV or TBX file (?dry_run=true to preview)
GET /api/v1/series/{id}/shared-glossaryseries:readThe shared glossary a series follows
PUT /api/v1/series/{id}/shared-glossarycontent:writeAttach a series to a shared glossary, or detach it
GET /api/v1/booksnarrative:readList your books
GET /api/v1/books/{id}/narrative-contextnarrative:readThe Narrative Context Bundle of a book: its text and its memory (see Narrative context)
GET /api/v1/books/{id}/narrative-context/revisionnarrative:readThe bundle’s fingerprints alone
GET /api/v1/series/{id}/narrative-contextnarrative:readThe Series Bible, the identities across volumes and the volumes in reading order

Narrative context

Another application (Libris Draw, a script of yours) reads what Libris understood of a book through one document, the Narrative Context Bundle, instead of reading the database. It needs a token with the narrative:read scope and nothing else; it is read only, starts no work and counts no word.

curl -H "Authorization: Bearer $LIBRIS_TOKEN" "$LIBRIS_URL/api/v1/books/$BOOK_ID/narrative-context"

The bundle holds the book and its series, content.chapters (each with its passages: source, the translation in force, the analysis’ summary and the character_ids it names) and narrative: the Book Bible, the character sheets (aliases, description, mentions, and observations with the words of the text they rest on), their relations, the places, organisations and objects the bible lists (world), the glossary and the events of each passage. Identifiers are Libris’ own and never change. Every item carries a provenance: origin is analysis when the model wrote it and human when a person validated it, so a client can tell a fact of the book from a guess.

?chapter_id= (repeatable) limits the text to some chapters, ?text=none leaves the text out; the narrative memory is always whole.

A bundle is a snapshot. It is versioned by schema_version (the shape, 1), generated_at, checksum (SHA-256 of its body) and three fingerprints: revision for the narrative memory, content_revision for the text, and one revision per chapter. They are hashes, not counters: equal means unchanged. To know whether a snapshot taken earlier is still current, compare them with GET …/narrative-context/revision, which answers the fingerprints alone. Libris never pushes a change to a client: the client decides when to take a new snapshot.

GET /api/v1/series/{id}/narrative-context answers what holds across the volumes: the Series Bible, the canonical identities (a character’s series_entity_id in a book’s bundle points to one of them), the series relations and glossary, and the volumes in reading order.

Driving Libris from a coding agent (MCP)

Besides this API, Libris answers the Model Context Protocol at POST /mcp, with the same tokens and the same scopes as here. An MCP client — opencode, Claude Code, Codex — then reads the library, sets a book up, keeps the glossary and steers the work in its own words. 28 tools, the permissions of the token deciding which of them answer. See mcp.md.

claude mcp add --transport http libris "$LIBRIS_URL/mcp" --header "Authorization: Bearer $LIBRIS_TOKEN"

OpenAPI description and example client

  • docs/openapi/libris-v1.json describes /api/v1 in OpenAPI 3.1: every operation with its scope, parameters, bodies (JSON document, multipart upload, raw EPUB), answers, error codes and examples, plus the webhook. Load it in any OpenAPI tool or client generator. It is generated from the code and checked by the test suite, so it always matches the version of Libris it ships with. The website shows it as a readable reference.

  • examples/libris_client.py is a small client in Python with the standard library only (Python 3.10 or newer, nothing to install). It sends an EPUB, TXT or DOCX chapters or a JSON document with any upload option, waits with long polling, downloads the result in any format and scope, waits out 429 rate_limited answers (it reports 429 queue_full instead of retrying), and receives webhooks after checking their signature:

    export LIBRIS_URL=https://libris.example.org LIBRIS_TOKEN=lbr_xxxxxxxx_...
    python3 examples/libris_client.py epub book.epub --target-language fr --provider-id "$PROVIDER_ID" --out book.fr.epub
    python3 examples/libris_client.py chapters "Chapter 1.txt" "Chapter 2.txt" --series "Web Saga" --volume 1 \
      --source-language en --target-language fr --format txt-zip --out volume-1.zip
    python3 examples/libris_client.py chapters "Chapter 41.txt" --series "Web Saga" --volume latest \
      --source-language en --target-language fr --format epub-bilingual --layout side-by-side --scope new
    LIBRIS_WEBHOOK_SECRET=... python3 examples/libris_client.py webhooks --port 8080
    

    Its LibrisClient class can also be imported in your own code; run it with --help for every option. The client sends the token only to the origin of LIBRIS_URL: it follows a redirect only when the scheme, host and port stay the same (at most five in a row), and refuses any other one — another server, another port, or https to http — with the error code redirect_refused, so neither the token nor the book leaves.

Every example on this page uses these shell variables:

export LIBRIS_URL=https://libris.example.org
export LIBRIS_TOKEN=lbr_xxxxxxxx_...        # shown once when the token is created
export PROVIDER_ID=...                      # a provider id, see "Choosing a provider" below

Create a token

  1. In the interface, open My account › API tokens. Tokens are personal: each account creates its own, administrators included.
  2. Under Create a token, give it a name, tick the permissions it needs and choose an expiration (30, 90 or 365 days, or never).
  3. Optionally tick Sign webhooks with a secret of this token (see Webhooks), set its Queue limits (see Queue priority and quotas) and give it a Token budget (see Token budget).
  4. Copy the secret now: it is displayed once and never again.

A token acts on behalf of its owner: it only sees the owner’s series and requests, and it stops working when the owner’s account is disabled. The list shows each token’s prefix, permissions, creation and expiry dates, last use (updated at most once a minute) and state (active, expired, revoked). Revoke is immediate and final; clients using the token then get 401. Creating and revoking a token are written to the audit log, without the secret. An account can hold at most 50 tokens that are not revoked.

ScopeInterface labelAllows
series:readRead seriesGET /api/v1/series, GET /api/v1/series/{id}, GET /api/v1/glossaries, GET /api/v1/glossaries/{id}, GET /api/v1/glossaries/{id}/export/{format}, GET /api/v1/series/{id}/shared-glossary
content:writeSend contentPOST /api/v1/translation-requests, GET /api/v1/providers, POST /api/v1/glossaries, POST /api/v1/glossaries/{id}/import, PUT /api/v1/series/{id}/shared-glossary
pipeline:startStart the pipelineTogether with content:write: requests that start the translation (the default). Without it, only start=false (import only) is accepted.
jobs:readFollow jobsGET /api/v1/translation-requests/{id}
jobs:controlControl jobs (pause, resume, cancel)POST …/pause, …/resume, …/cancel
results:readRead resultsGET /api/v1/translation-requests/{id}/result
narrative:readRead the narrative context (text and memory of the books)GET /api/v1/books, GET /api/v1/books/{id}/narrative-context, GET /api/v1/books/{id}/narrative-context/revision, GET /api/v1/series/{id}/narrative-context

A token has the form lbr_ + 8 identifying characters + _ + a random secret (256 bits). Libris stores only its SHA-256 and compares it in constant time.

Managing tokens from a script

The interface manages tokens through these routes, which use the session cookie, not a token:

RouteBody and answer
GET /api/tokensThe caller’s tokens: id, name, prefix, scopes, created_at, expires_at, revoked_at, last_used_at, state, webhook_secret (a boolean: whether the token has its own signing secret), max_priority, max_running, max_queued (see Queue priority and quotas) and budget (null without a cap, else {amount, period, spent, resets_at}, see Token budget).
POST /api/tokensBody {"name": "…", "scopes": ["…"], "expires_in_days": 90, "webhook_secret": false, "budget_amount": null, "budget_period": "month"}, optionally with max_priority (low, normal (default) or high), max_running (1–1000) and max_queued (1–100000). name 1–100 characters, at least one scope, expires_in_days 1–3650 or null for no expiry, budget_amount a positive cap or null, budget_period month or total. Answers 201 with the token view plus token (the secret) and, when asked, webhook_secret (the signing secret). This is the only answer that ever contains them. 409 once the account holds 50 tokens that are not revoked; 403 priority_not_allowed when max_priority is above the account’s own ceiling.
PUT /api/tokens/{id}/queueBody {"max_priority": "normal", "max_running": null, "max_queued": null}: changes the token’s queue limits without changing its secret; returns its view. 403 priority_not_allowed when max_priority is above the account’s own ceiling.
PUT /api/tokens/{id}/budgetBody {"amount": 50, "period": "month"} (amount: null removes the cap). Returns the token view. Written to the audit log.
DELETE /api/tokens/{id}Revokes the token and returns its view.

Queue priority and quotas

Libris shares its providers between accounts with a fair queue: waiting jobs start by priority, then from the account (and the token) with the fewest jobs running, in turn between accounts; the volumes of a series run one at a time, in reading order (see architecture). A request may ask for a priority, pipeline.priority in a JSON document or the priority option of a file upload: low, normal (the default) or high.

  • The priority may not exceed the token’s max_priority (normal unless set otherwise) nor the account’s ceiling (high for administrators and for accounts an administrator allowed in Settings › Queue, normal otherwise; an administrator may also lower an account to low there). Above it, the request is refused with 403 priority_not_allowed and max_priority in the error. Without a priority, a request runs at normal, or at the lower ceiling of a token or an account limited to low.
  • max_running limits the token’s jobs running at once: the next ones wait (queue.reason is token_limit). The account’s own limit (QUEUE_MAX_RUNNING_PER_ACCOUNT or its row in Settings › Queue) applies as well (account_limit).
  • max_queued limits the token’s requests and jobs waiting to start. A new request over it, or over the account’s waiting quota, is refused with 429 queue_full and scope (token or account) and limit in the error; nothing is stored. Retry once one of them has started, not at once: unlike rate_limited, this answer has no Retry-After. A replay of an accepted request (same Idempotency-Key or external_id) is still answered. Resuming a paused request counts as a new entry in the queue, so …/resume may answer 429 queue_full too.

The priority does not change the request’s content: sending the same request again with another priority is a replay of the first one.

Token budget

A token may have a spending cap, in the currency the provider prices are entered in (the one of usage.cost in the reports). It counts the model calls of the requests made with the token, per calendar month (UTC, period: "month") or over the token’s whole life (period: "total"). A request that has ended counts with the cost kept on it, in the month it ended; a running one counts what its calls have cost so far.

  • Once the cap is reached, a new request that would start work (start true, the default) is refused with 402 budget_exceeded; the error carries budget: {amount, spent, period, resets_at} (resets_at: start of the next month, null for a total cap). An import alone (start: false) costs nothing and is still accepted.
  • A running request whose token nears its cap is handled like a book near its budget (see cost budgets): its job moves to a cheaper fallback provider, or pauses with the stop reason budget_exceeded. …/resume answers 409 budget_exceeded until the cap is raised (PUT /api/tokens/{id}/budget). A request left paused longer than API_REQUEST_STALL_MINUTES fails with that reason.
  • The caps are checked again when the request’s job starts (right away, or once the volume is free): the book’s budget and the token’s, a cap past the switch threshold with no cheaper provider, and, when the installation refuses launches whose estimate exceeds what is left (BUDGET_ON_ESTIMATE=refuse), the estimate. A request accepted with 202 that is refused at that point ends failed, with the reason in error.
  • Synchronous calls in flight reserve budget room across all jobs of the same token, including jobs using different providers. Near the switch threshold, the next paid call waits for those calls to settle; the ordinary cheaper-provider/pause decision is checked again just before admission. This preserves the existing indivisible-last-call policy, not a prepaid-wallet guarantee: a single call can cross the cap. Cancellation or a timeout also cannot refund work already sent upstream. Different tokens keep separate monetary caps; account word allowances and queue quotas are separate.

Licence word quota

The licence’s word quota is the installation’s, not the token’s: it counts source words in the licence’s quota cycle (a month anchored on the subscription date; the calendar month with an older licence server). What a cycle overran is taken from the next one.

  • A book is counted when it is added (0.17.0): all of its words, at the import, even if it is deleted afterwards without being translated. Translating it, launching it again or redoing a passage then costs nothing more. A request whose chapters do not fit in what the cycle has left plus the margin LICENCE_QUOTA_OVERRUN_WORDS (20,000 words by default) is refused whole with 402 licence_quota_insufficient — or 402 allowance_insufficient for the account’s own monthly allowance — with words and left (margin included); nothing is imported. Send it again once the quota allows.
  • A book added before 0.17.0 is still counted passage by passage as it is translated; such a book whose words still to translate exceed what the cycle has left is not queued, and a request that would start it ends failed with stop_reason: "licence_quota_insufficient".
  • Starting a whole series (…/series/{id}/jobs/start) answers, per refused volume, code: "licence_quota_insufficient": each volume must fit in what the volumes started before it in the same call left.
  • The MCP tool start_job, like POST /api/projects/{id}/jobs in the browser, answers 402 licence_quota_insufficient for a whole-book translation, with words (what the book would cost) and left.
  • A licence without a word limit never refuses. A book already counted is translated even once the quota is reached; only a provider comparison, which calls the models again, is counted and stopped.

Send a translation request

POST /api/v1/translation-requests accepts three kinds of input:

InputHow to send itDefault result
An EPUBmultipart/form-data with one .epub file in the field file, options as form fields; or the raw file as Content-Type: application/epub+zip, options in the query stringThe translated EPUB
TXT or DOCX chaptersmultipart/form-data with one or more .txt or .docx files in file or files, options as form fields; one file can be split at its chapter headingsJSON
A JSON documentContent-Type: application/json; or one .json file in the multipart field file (with no other form field)JSON (or output.format)

One request carries one kind of file: mixing .epub, .txt, .docx and .json files, or sending several EPUB or JSON files, is refused with 422. Markdown and HTML files are not accepted here: import them through the interface.

Every accepted request answers 202 Accepted, with a Location header pointing to its status:

{
  "request_id": "5b1c…",
  "external_id": "tbate-volume-12",
  "series_id": "…",
  "project_id": "…",
  "job_id": "…",
  "input": "json",
  "status": "pending",
  "status_url": "/api/v1/translation-requests/5b1c…",
  "result_url": "/api/v1/translation-requests/5b1c…/result"
}

input is epub, txt, docx or json. job_id is null while the request waits for its volume (status: "queued").

Choosing a provider

A translation needs a model provider. Libris uses, in order, provider_id from the request, the volume’s provider, then the series’ default provider. If none is set, the request is refused with 422 provider_required; an unknown id answers 422 unknown_provider.

The simplest setup is to choose a default provider for the series once, in the interface (the series’ Defaults tab), and leave provider_id out. To find provider ids, list the providers with a token that has the content:write scope:

curl -sS "$LIBRIS_URL/api/v1/providers" -H "Authorization: Bearer $LIBRIS_TOKEN"
[{"id": "…", "name": "Local", "kind": "openai", "model": "qwen3-32b", "created_at": 1789000000.0,
  "default_for_series": ["…"]}]

The list is sorted by name. kind is the connection type (openai, openai_direct, openai_responses, anthropic or codex_chatgpt), and default_for_series lists the ids of your series that use the provider by default. The provider’s address and API key are never included. Providers are managed by administrators in the interface.

Send an EPUB

# Multipart: the file and its options as form fields
curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -H "Idempotency-Key: silver-tower-fr-1" \
  -F "file=@The Silver Tower.epub;type=application/epub+zip" \
  -F series="Silver Saga" -F volume=1 \
  -F source_language=en -F target_language=fr \
  -F provider_id="$PROVIDER_ID" -F quality=high

# Raw body: the options in the query string
curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests?target_language=fr&provider_id=$PROVIDER_ID&filename=tower.epub" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -H "Content-Type: application/epub+zip" \
  --data-binary @tower.epub

What Libris does with it:

  • The EPUB becomes a volume: standalone, or in the series named by series (created if missing) or series_id. Its pipeline starts at once.
  • The same file sent again (same bytes) reuses the volume already made from it, and the request records that decision. An archived volume answers 409 volume_archived.
  • In a series, without volume, Libris takes the volume number from the file name when that number is free, otherwise the number after the last volume. A file name that marks a special volume — a + or a decimal after the number (LN 14+, Vol. 3.5) — gets no number at all: the volume is created unnumbered, since guessing 14 would collide with the real volume 14. The choice and its reason are recorded in the report (decisions.intake). A volume number already used by another book answers 409 volume_conflict.
  • Languages default to the language declared in the EPUB (en when none) and to the series’ target language (fr when there is none).
  • Each chapter is named from its heading — kind, number, part, label — exactly as a chapter sent in JSON is (see Irregular chapters); the EPUB’s own order is the reading order and is never changed, and its table of contents and auxiliary pages stay out of the numbering. What was detected is in the status document (chapters[].mapping) and in the completion report (chapter_map).
  • A file that cannot be read as an EPUB answers 422 invalid_epub; a fixed-layout (pre-paginated) EPUB, which Libris cannot translate without the text overflowing its fixed pages, answers 422 fixed_layout_epub. When only some of its text pages are fixed, the EPUB is accepted and the decision says how many.
  • If a job is already running on the volume, its settings are left alone and the request waits for it (the decision is recorded).

Send TXT or DOCX chapters

curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -F "files=@Chapter 1.txt" -F "files=@Chapter 2.txt" -F "files=@Afterword.txt" \
  -F series="Web Saga" -F volume=1 \
  -F source_language=en -F target_language=fr \
  -F provider_id="$PROVIDER_ID" -F output_format=txt-zip

Each file becomes one chapter, and the request behaves exactly like the JSON document it stands for (same idempotency, volume lookup, conflicts and results). series (or series_id), volume, source_language and target_language are required.

  • Encoding. Files are decoded as UTF-8, UTF-16 with a BOM, or, as a last resort, Windows-1252. The last case is recorded in the report.
  • Chapter numbers come from the file names (Chapter 12.txt, 012 - Title.txt, or the part that varies across the batch). A file with no number, or with the same number as another file, gets the next free number in upload order. These choices are never questions: each one is recorded with its reason in report.decisions.intake.
  • Chapter map. The names also give the parts and the specials, as in the JSON document: Chapter 12 - Part 2.txt, Ch12 (2-2).txt, 12a.txt / 12b.txt, 12.1.txt with 12.2.txt (parts when both are sent without a chapter 12), 第12章(下).txt; Prologue.txt, Interlude – Ayla.txt, Side Story 3.txt, Afterword.txt, Author's Note.txt… A special needs no number: a prologue goes first, an epilogue or an afterword last, another special after the chapter before it in the name order; a place that rests only on the alphabetical order is recorded in report.decisions.intake with a low confidence.
  • Titles are taken from the file names.
  • DOCX files are sent the same way (.docx instead of .txt, one kind per request): each one becomes a text chapter made of its paragraphs, one per line; formatting is not kept.

One file holding many chapters

A webnovel often comes as one big file. Send it alone with split=headings and Libris cuts it at its chapter headings, without a preview:

curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -F "file=@The Glass Road.txt" -F split=headings \
  -F series="Glass Road" -F volume=1 \
  -F source_language=en -F target_language=fr
  • Headings. DOCX heading styles (Heading 1, Titre 1…) first; otherwise lines such as Chapter 12, Chapitre 12 : Title, CHAPTER XII, Chapter One, Chapitre Premier, 第12章, Prologue, Epilogue, Interlude – Ayla, Side Story 2: Title, Author's Note, 番外一; otherwise numbered lines (1. Title, 2. Title…) only when they follow each other. A sentence that mentions a chapter does not split, and a table of contents (headings with no text between them) is skipped. Spelled-out French and English cardinals and ordinals are recognized up to 999, with spaces or hyphens (Twenty-One, quatre-vingt-dix-neuf). Higher word numbers require an explicit number; an unsupported One Thousand is never silently read as chapter 1.
  • Chapters. Each heading starts a chapter titled by it; its text follows the heading. Text before the first heading becomes a front matter chapter (kind front_matter), named in the volume’s source language rather than the interface language, when it has words. Numbers come from the headings; Chapter 12 (1/2) and Chapter 12 (2/2) are the two parts of chapter 12. A prologue, an interlude, a side story or an epilogue keeps its kind and its place in the file, with no chapter number. A styled heading that names no chapter is numbered between its neighbours. A heading whose number goes back stays in the previous chapter.
  • Report. The split is a decision in report.decisions.intake (split: the number of chapters, with the reason); missing numbers and skipped headings are listed there too. A file with fewer than two headings stays one chapter, and that is recorded as well.
  • Status. chapters.items of the status document lists the chapters created, with their number and title. Sending the same file again finds the same chapters (unchanged), as for any chapter.
  • split=headings takes exactly one file (422 invalid_payload otherwise) and is refused for an EPUB. Without it (split=none, the default), each file stays one chapter.

Send a JSON document

{
  "external_id": "tbate-volume-12",
  "series": {"id": null, "name": "The Synthetic Saga", "create_if_missing": true},
  "volume": {"external_id": "volume-12", "number": 12, "title": "Volume 12"},
  "author": "A. Author",
  "source_language": "en",
  "target_language": "fr",
  "chapters": [
    {"external_id": "chapter-001", "number": 1, "title": "Chapter 1",
     "content": "First paragraph.\n\nSecond paragraph.\n"}
  ],
  "replace_changed_chapters": false,
  "discard_human": false,
  "pipeline": {"start": true, "provider_id": null, "quality": "high",
               "context_backend": "hybrid", "final_review": true, "priority": "normal"},
  "output": {"format": "json"},
  "callback_url": "https://hooks.example.org/libris"
}
curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: tbate-volume-12-run-1" \
  --data @request.json

# The same document as an uploaded file
curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -H "Idempotency-Key: tbate-volume-12-run-1" \
  -F "file=@request.json;type=application/json"
FieldRequiredRules
external_idnoYour identifier for the request: starts with a letter or digit, then letters, digits and ._:/-, up to 200 characters. Unique per owner (see Sending twice).
seriesyesid (a series you own) or name. An unknown name is created when create_if_missing is true (the default), otherwise 404 series_not_found. An archived series answers 409 series_archived.
volume.numberyes, unless volume.latest1–10000.
volume.latestnoDefault false. true instead of a number: the chapters go to the series’ last volume (the highest number; the series’ continuous chapter feed when it has no numbered volume; volume 1, created, when it has neither). See Following a series over time.
volume.external_id, volume.titlenoThe volume is found by external_id, then by number within the series; otherwise it is created (title defaults to “Series — number”). A volume that came from an EPUB answers 409 volume_conflict, and so does a volume with that number but another external_id. An archived volume answers 409 volume_archived.
authornoUp to 500 characters.
source_language, target_languageyesBCP 47 tags such as en, fr-FR, zh-Hant, es-419. Applied to the volume.
chaptersyes1 to API_MAX_CHAPTERS (2000) chapters. number (0–100000, decimals such as 12.5 allowed) is required for a chapter unless its title gives it; a prologue, an interlude or another special needs none (see Irregular chapters). Numbers (with their part) and external_ids must not repeat. content must not be blank and holds at most TEXT_CHAPTER_MAX_CHARS characters. title is optional; without it nothing is added to the text and exports name the chapter by its label (Chapitre 12, Prologue). Chapters are ordered by number and part, a special after the chapter it follows in this list.
replace_changed_chaptersnoDefault false: a chapter already in the volume (same external_id or same number) with a different text answers 409 chapter_conflict with the list in conflicts. With true, it is replaced; passages whose text did not change keep their translation.
discard_humannoDefault false: a replacement that would drop passages a person corrected or validated answers 409 conflict with protected_segments. With true (and replace_changed_chapters), those edits are discarded.
pipeline.startnoDefault true: run the whole pipeline (needs the pipeline:start scope). false only imports the chapters.
pipeline.provider_idnoSee Choosing a provider.
pipeline.qualitynofast, normal, high or maximum (see the autopilot guide).
pipeline.context_backendnointernal, openviking or hybrid (see OpenViking).
pipeline.final_reviewnoDefault true. false skips the final review. It never runs when the server sets FINAL_REVIEW_ENABLED=false.
pipeline.prioritynolow, normal (default) or high, within the token’s ceiling (see Queue priority and quotas).
pipeline.analysis_modenoparallel or strict; default: the volume’s choice, else ANALYSIS_MODE (parallel). See Analysis modes.
pipeline.threadsno1–64: passages of this volume worked on at once, analysis and translation alike. It can only lower the volume’s share of the provider’s capacity. Default: the volume’s choice, else that share.
pipeline.escalation_provider_idnoThe volume’s stronger model, kept for its hard cases: a passage that comes back to the AI arbitration, or that no rung of the recovery could translate. No step of the book moves to it. A setting of the volume: it holds for the requests that follow. An empty string clears it; default: unchanged, then the server’s own choice.
output.formatnoDefault format of the result: json, txt, txt-zip or epub-bilingual.
callback_urlnoA webhook called when the request ends (see Webhooks).
callback_eventsnoExtra webhook events, on top of the final one: ["chapters.translated"] sends a batch each time chapters of the request are translated (see Batches of translated chapters). At most 5 items. Only used with a callback_url: without one, the events are accepted and ignored.

Unknown fields are refused. Libris never downloads anything from a URL found in the document: text is taken as it is. Chapters go through the same text importer as TXT files (same passages, layout and checksums), and the normalized document is stored as a source file of the volume.

A new volume, and a series created by the request, take provider_id, quality and context_backend from pipeline when given, otherwise from the series defaults.

Irregular chapters: parts, prologues, interludes

Webnovels publish chapters in several parts and add prologues, interludes, side stories, bonus chapters, afterwords and author’s notes between the numbered chapters. Each chapter of the document may say what it is; anything left out is read from its title:

"chapters": [
  {"external_id": "p0", "kind": "prologue", "content": "…"},
  {"external_id": "c12a", "number": 12, "part": 1, "part_count": 2, "content": "…"},
  {"external_id": "c12b", "number": 12, "title": "Chapter 12 (Part 2)", "content": "…"},
  {"external_id": "i1", "title": "Interlude – Ayla", "content": "…"},
  {"external_id": "c13", "number": 13, "content": "…"},
  {"external_id": "ss3", "kind": "side_story", "number": 3, "label": "The Past", "after": "c12b", "content": "…"}
]
FieldMeaning
kindchapter (default), prologue, interlude, side_story, extra, epilogue, afterword, author_note or front_matter. Absent: read from the title (Prologue, Épilogue, Interlude – Ayla, Side Story 3, 番外, Author's Note…); a numbered title such as Chapter 12: Prologue to War stays a chapter.
numberFor a chapter, its number (12.5 is a real half chapter). For a special, its own number (Interlude 2 is the second interlude, not chapter 2); a special does not take part in the chapter numbering.
part, part_countPart of a chapter published in several (1–999) and, when known, how many parts it has (part may not exceed part_count). Absent: read from the title (Chapter 12 (Part 2), Ch12 (2/2), Chapitre 12 partie 2, 第12章(下)) when it names the same chapter.
labelThe name shown after the kind (Ayla in Interlude – Ayla), at most 200 characters.
afterInsert after this chapter: its Libris chapter_id, its external_id (in the volume or in this request), a chapter number (after that chapter, its parts and the specials already placed after it) or start. An unknown one answers 422 invalid_placement. Sent with a chapter already in the volume, it moves it.
positionInsert at this reading position (0–100000; 0: first). Not with after.

Reading order. A volume’s chapters are ordered by their reading position, not by their numbers: analysis, context and memory (a passage only sees what precedes it), parallel analysis, the no-spoiler rules, quality scores, follow-ups and exports all follow it. Without after or position, a numbered chapter goes after the chapters with a lower number or part, so a part 2 sent after its part 1 was translated lands right after it (and sees it as its preceding context). A special goes after the chapter it follows in the request; a prologue or front matter with nothing before it goes first; an epilogue or an afterword with nothing after it goes last; a special sent alone in a follow-up goes after the last chapter (before a closing epilogue). Several specials in a row keep their order. The chapters after an inserted one are marked for a new context check.

Matching. A chapter sent again is found by external_id, then by kind, number and part (a lone chapter 12 and its part 1 are the same chapter), then, for a special without a number, by kind and label or title. Documents written before the chapter map keep working: a special detected from its title keeps the order its number gave it (a prologue sent as 0, an interlude as 5.5), and a document without the new fields is stored, and deduplicated, as before.

Report. The status document gives each chapter’s kind, number, part, part_count, label, display_label (the label in the source language), reading position and mapping (confidence, reason, and detected when it was read from the title); the completion report lists the same map in chapter_map.

Analysis modes and threads

Before translating, Libris analyses the volume: characters and their names, relations, terms, chapter summaries, then the Book Bible. Translation always starts once this analysis is complete.

  • parallel (the default): every passage is first analysed on its own, threads at a time; the results are consolidated in book order, and each passage is then reviewed again, in parallel, against what the passages before it established (who a nickname or a pronoun refers to, which names are the same person). Nothing a later passage reveals is ever shown to an earlier one. A numbered volume of a series also waits, before that review, until an earlier volume that is being analysed at the same time has finished its analysis (the job is then waiting with stop_reason: earlier_volume, and queue.reason is earlier_volume). That wait lasts API_REQUEST_STALL_MINUTES at most; after it, the volume goes on with the series memory available. It makes about 1.8 times more analysis calls than the strict mode, and ends several times sooner (see architecture).
  • strict: one passage after the other, each reading the memory left by the previous ones.

threads limits how many passages of the volume are in flight at once, for the analysis and the translation. Without it, a volume uses the provider’s capacity (Concurrent books), shared equally between the books running on it, and never more: a higher value is ignored. After a provider answers 429 or is overloaded, the job waits, then resumes at half its width and widens again by one passage per minute. Near a cost budget, the calls in flight are counted before they start, and the volume narrows down to one call at a time.

Options of file uploads

For EPUB and TXT uploads, options are form fields (or query parameters for a raw EPUB body). Empty values count as “not given”; unknown options are refused.

OptionMeaning
series or series_idThe series by name (created when missing) or by id; not both. Required for TXT, optional for an EPUB (standalone volume otherwise).
volumeVolume number (1–10000), or latest for the series’ last volume (TXT only, see volume.latest above). Required for TXT.
external_idYour identifier of the request.
volume_external_idYour identifier of the volume.
title, authorVolume title and author (an EPUB keeps its own otherwise).
source_language, target_languageBCP 47 tags. Both required for TXT.
provider_id, quality, context_backend, final_review, priority, analysis_mode, threadsAs in pipeline above.
starttrue (default) runs the whole pipeline; false only imports.
output_formatepub (EPUB input only; the default for an EPUB), json, txt, txt-zip or epub-bilingual.
callback_urlSee Webhooks.
callback_eventsComma-separated extra events, for example chapters.translated (see callback_events above).
replace_changed_chapters, discard_humanTXT and DOCX only, as in the JSON document.
splitTXT and DOCX only: headings cuts one file at its chapter headings (details); none (default) keeps each file as one chapter.
filenameRaw EPUB body only: the file name, used to guess the volume number.

Sending the same request twice

Send an Idempotency-Key header (1–200 printable characters), an external_id, or both. Sending the same content again with the same key or external_id answers 200 OK with the original request and the header Idempotent-Replayed: true: nothing is created twice. The same key or external_id with different content answers 409 idempotency_conflict.

“Same content” is compared after validation: key order and whitespace do not matter, and a JSON document and the multipart upload of the same chapters are equivalent. priority is never part of it. For an EPUB, the content is the file plus its options, except callback_url, callback_events, priority and split.

Even without a key, sending chapters that are already in the volume with the same text never duplicates a series, a volume or a chapter: they are reported as unchanged.

Following a series over time

A webnovel is translated as it is published: send each new batch of chapters as its own request, to the same series and volume (or with volume.latest: true, volume=latest for TXT files, to follow the series’ last volume without tracking its number).

curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -F "files=@Chapter 51.txt" -F "files=@Chapter 52.txt" \
  -F series="Web Saga" -F volume=latest \
  -F source_language=en -F target_language=fr \
  -F callback_url=https://hooks.example.org/libris -F callback_events=chapters.translated
  • Appended in order. Chapters are placed by number and part among the chapters already in the volume, specials after the chapter they follow or where after puts them (see Irregular chapters), and matched by external_id, then by kind, number and part: a chapter sent again with the same text is unchanged, one with another text is refused unless replace_changed_chapters is true. Give numbers in the file names (Chapter 51.txt): a file without a number is numbered within its own request only.
  • Lines of the source site. A request has no preview: it leaves out only the lines the volume already leaves out, recorded when chapters were imported through the import assistant with Exclude these lines ticked. They are taken out of the first and last five paragraphs of each chapter sent, before its words are counted; the same words elsewhere in a chapter are kept, and a volume created by the API records none.
  • Only the new chapters are translated. Chapters already translated are neither translated nor reviewed again: they give their context (glossary, characters, summaries and the previous passages) to the new ones. The request’s job covers the new or replaced chapters, plus any chapter of the volume still missing a translation. The status document lists them in chapters.new.
  • One request at a time per volume. A request sent while the volume is busy waits (queued) and starts after the running one.
  • Updated results. Each request’s result can cover its own chapters, only the new ones, or the whole volume (see scope in Get the result).

Follow a request

curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID" \
  -H "Authorization: Bearer $LIBRIS_TOKEN"

# Long poll: answers as soon as the request ends, or after 60 seconds at most
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID?wait=60" \
  -H "Authorization: Bearer $LIBRIS_TOKEN"

?wait=<seconds> holds the answer until the request ends, up to API_RESULT_MAX_WAIT_SECONDS (60 by default, 600 at most); a larger value is cut to that limit, and values above 600 are refused with 422. Nothing is held open in the database while waiting.

How a request moves

  1. Queued. If another job is working on the volume, the request waits as queued. Its chapters are imported only once no job is active on the volume. The worker checks queued requests every 2 seconds and starts each one as soon as its volume is free; requests on the same volume start in arrival order. A volume held by a paused or blocked job stays busy until that job is resumed and finishes, or is cancelled.
  2. Running. The pipeline runs under the autopilot: refusals, invalid model answers and open review points never wait for a person, and a provider outage switches to fallback providers after a bounded wait. (If an administrator turned the autopilot off, or the volume opted out, these steps behave as in the interface.)
  3. Finalizing. The job ended; Libris builds and stores the result, then writes the report.
  4. Ended. One of the final statuses below.

Everything lives in the database: restarting the API or the worker loses nothing.

StatusMeaning
queuedWaiting for the volume to be free.
importedChapters imported, nothing started (start was false). This is an end state.
pending, runningThe job is waiting for a worker, or working.
pausedPaused by you or by a person in the interface.
waitingThe provider is temporarily unavailable, retried automatically; or, with stop_reason: earlier_volume, the analysis waits for an earlier volume of the series.
blockedNeeds attention, for example the provider refuses its credentials.
finalizingThe job ended; the result is being built.
completedEvery passage is translated; the result is stored.
completed_with_residualsThe result is stored, but some passages kept their source text (or were given back in it by the model) or the autopilot left unresolved issues or checks. Source passages are listed in report.residuals; remaining checks are counted in report.autopilot.
failedSee error: the job failed, the EPUB could not be repaired, the job stayed stalled too long, or the request ran past its maximum duration.
cancelledCancelled by you or by a person.

No request runs forever. If its job stays paused, blocked or waiting for more than API_REQUEST_STALL_MINUTES (360), Libris cancels the job and the request fails with the reason. A job waiting for an earlier volume (earlier_volume) is exempt: that wait has its own bound (see Analysis modes). The same happens to a request still unfinished API_REQUEST_MAX_HOURS (168) after it was created. When the autopilot reports that it failed, the request fails with the autopilot’s reason.

The status document

{
  "request_id": "5b1c…", "external_id": "tbate-volume-12", "series_id": "…", "project_id": "…",
  "job_id": "…", "input": "json", "status": "running",
  "status_url": "/api/v1/translation-requests/5b1c…",
  "result_url": "/api/v1/translation-requests/5b1c…/result",
  "created_at": 1790000000.0, "updated_at": 1790000100.0, "finished_at": null,
  "stage": "translation", "step": "translation",
  "progress": {"segments": 412, "translated": 180, "percent": 44,
               "stages": [{"key": "translation", "done": 180, "total": 412, "percent": 44}],
               "analysis": null},
  "estimate": {"…": "…"},
  "error": "", "stop_reason": "", "next_attempt": 0,
  "chapters": {"created": 3, "unchanged": 0, "replaced": 0, "new": ["…"],
               "items": [{"chapter_id": "…", "external_id": "chapter-001", "number": 1, "kind": "chapter",
                          "part": null, "part_count": null, "label": "", "display_label": "Chapter 1",
                          "position": 0, "mapping": {"confidence": "high", "reason": "…", "detected": false},
                          "title": "Chapter 1",
                          "segments": 140, "translated": 60, "validated": 0, "flagged": 0, "complete": false}]},
  "options": {"start": true, "final_review": true, "output_format": "json", "analysis_mode": null, "threads": null},
  "priority": "normal",
  "queue": null,
  "result": null,
  "report": null,
  "webhook": {"state": "pending", "attempts": 0, "error": ""},
  "chapter_events": null
}
FieldMeaning
stageCurrent stage of the volume: import, analysis, translation, review or export (null without a job).
stepCurrent step of the job (for example translation, final_review, autopilot, arbitration).
progresssegments, translated and percent for the request’s chapters (every chapter for an EPUB), and stages, the volume’s progress per stage. While the volume is analysed, analysis says where: step (extraction, consolidation, reconciliation, memory, then book_bible in the parallel mode; chapter_analysis, then book_bible in the strict mode), current/total passages or syntheses, level/levels of the Book Bible tree, and percent of the whole analysis; null otherwise.
estimateRemaining time and cost, once enough model calls have been observed; otherwise null.
error, stop_reason, next_attemptWhy the job stopped or is waiting, and when it will retry (Unix time, 0 when not waiting).
priorityThe request’s priority (low, normal, high), as changed by a person in the interface if it was.
queueWhile the request waits to start: position (its place in the line of its provider, 1 = next), reason (starting, provider_busy, account_limit, token_limit, retry_scheduled, earlier_volume while the analysis waits for an earlier volume of the series, provider_missing, or volume_busy while another job holds the volume), effective_priority (raised by waiting) and next_attempt. position is null for retry_scheduled, earlier_volume and volume_busy; with volume_busy the object has only position and reason; reason is null for a queued request that will not start (start: false). queue itself is null once the job runs or the request ended.
chaptersHow many chapters were created, unchanged or replaced, new (the ids of the created and replaced ones), and per chapter, in reading order, its map (kind, number, part, part_count, label, display_label, position, mapping), its passages, translated, validated and flagged counts, and whether it is complete.
resultOnce stored: format, media_type, filename, size, sha256, created_at.
reportThe completion report, once the request ended.
webhookOnly when a callback_url was given: state (pending, delivered, failed), attempts, last error.
chapter_eventsOnly when callback_events was given: batches queued so far, how many are delivered, pending or failed, waiting_chapters (not translated yet) and the last error.

Times are Unix timestamps in seconds.

Pause, resume or cancel

curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/pause"  -H "Authorization: Bearer $LIBRIS_TOKEN"
curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/resume" -H "Authorization: Bearer $LIBRIS_TOKEN"
curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/cancel" -H "Authorization: Bearer $LIBRIS_TOKEN"

Each answers with the status document. They follow the same rules as the interface: 409 when the job’s state does not allow the action. A request that has no job can only be cancelled while it is queued; any other action on a request without a job (pausing or resuming a queued request, or cancelling an imported one) answers 409 not_started. Resuming may also answer 429 queue_full (the waiting quota) or 409 budget_exceeded (a budget still reached).

Get the result

# The default format: the translated EPUB for an EPUB, otherwise the request's output format
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/result" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -o book.fr.epub

# JSON
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/result?format=json" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -o result.json

# One UTF-8 text file, chapters under their translated headings
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/result?format=txt" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -o volume-12.txt

# ZIP: chapters/001 - Title.txt …, manifest.json with the SHA-256 of each file
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/result?format=txt-zip" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -o volume-12.zip

# Bilingual EPUB for proofreading: each source paragraph with its translation (any input)
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/result?format=epub-bilingual&layout=side-by-side" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -o volume-12-bilingual.epub

# Whatever is ready so far
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/result?format=json&partial=true" \
  -H "Authorization: Bearer $LIBRIS_TOKEN"

Choosing the format. ?format= wins (epub, json, txt, txt-zip, epub-bilingual); otherwise the Accept header (application/epub+zip, application/json, text/plain, application/zip); otherwise the request’s own format. epub exists only for a request that sent an EPUB (409 format_unavailable otherwise).

Bilingual EPUB. epub-bilingual exists for every request, whatever was sent: a new EPUB 3 with one page per chapter, where each source paragraph is followed by its translation (layout=interleaved, the default) or placed next to it in two columns (layout=side-by-side; the columns stack on a narrow screen). It carries the text only (no image, no original styling) and is meant for proofreading on an e-reader. In a partial result, a passage without translation shows its source and an empty translation marked —. A stored bilingual result is the interleaved one; layout=side-by-side is rendered on demand. When EPUBCheck is installed and refuses the book, the answer is 422 delivery_failed.

What it covers. ?scope= chooses, in reading order:

scopeChapters
request (default)The chapters the request sent, unchanged ones included; for an EPUB, the whole book.
newOnly the chapters the request created or replaced: the new chapters of a follow-up.
volumeEvery chapter of the volume, those of earlier requests included: the updated volume.

The epub format is always the whole book. The JSON result says its scope. With new or volume, complete (and X-Libris-Complete) is true only when every chapter covered is fully translated and the autopilot reports no unresolved issue or check.

Stored and rendered results. When a request ends successfully, Libris builds its result once, in the request’s default format and the request scope, and stores it under DATA_DIR/results/<request id>/. That file is served as is. Other formats are rendered on demand from the database. Stored files are removed after RETENTION_RESULTS_DAYS (30 days); asking again then renders the result from the database.

Before the end. While the request is not finished, the answer is 409 result_not_ready with status, incomplete_chapters and a Retry-After: 5 header. A failed or cancelled request answers 409 request_failed or 409 request_cancelled with the reason. Add ?wait=<seconds> to wait for the end first, or ?partial=true to get what is ready: then complete is false, and incomplete_chapters lists the chapters that are not fully translated.

Missing passages keep their source text in every format: residuals of a completed_with_residuals request, and passages not yet translated in a partial result.

Every result carries two headers: X-Libris-Complete: true|false and X-Libris-Status: <status>. Files other than JSON also carry a Content-Disposition with a file name. If the volume was deleted, the answer is 404 volume_not_found.

The delivered EPUB

The translated EPUB is rebuilt from the original file with every translated passage. Passages without a usable translation keep their source text and markup: passages not translated, passages kept in the original by the autopilot, and passages whose translated markup no longer matches the source (markup_mismatch).

The book is then checked with EPUBCheck (when the server has EPUBCHECK_JAR). If EPUBCheck rejects it, Libris repairs it on its own: the files named by the errors go back to their source text (the passages concerned become residuals with the reason epubcheck_repair), and the book is checked again, up to DELIVERY_REPAIR_ATTEMPTS (3) times. Errors the original EPUB already had are not caused by the translation: they are listed in report.delivery.inherited_errors and do not block delivery. Only when the repairs run out does the request fail, with the errors in report.delivery.errors. When the EPUB is rendered on demand and cannot be built, the answer is 422 delivery_failed.

The JSON result

{
  "schema_version": 1,
  "request_id": "…", "external_id": "tbate-volume-12", "status": "completed", "complete": true,
  "scope": "request",
  "series": {"id": "…", "name": "The Synthetic Saga"},
  "volume": {"project_id": "…", "external_id": "volume-12", "number": 12, "label": null, "title": "Volume 12"},
  "source_language": "en", "target_language": "fr",
  "strategy": {"provider": {"name": "Local", "model": "…"}, "quality": "high",
               "context_backend": "hybrid", "final_review": true},
  "incomplete_chapters": [],
  "chapters": [{
    "chapter_id": "…", "external_id": "chapter-001", "number": 1,
    "kind": "chapter", "part": null, "part_count": null, "label": "",
    "display_label": "Chapitre 1", "position": 0,
    "title": "Chapter 1", "translated_title": "Chapitre 1",
    "complete": true, "missing_segments": 0,
    "translation": "Premier paragraphe.\n\nDeuxième paragraphe.\n",
    "source_sha256": "…", "sha256": "…",
    "review": {"segments": 2, "validated": 0, "flagged": 0},
    "issues": [], "flagged_passages": []
  }],
  "report": {"…": "the completion report"}
}
  • sha256 is the SHA-256 of translation (UTF-8); source_sha256 is that of the normalized source text.
  • Chapters are listed in reading order (position), one entry per stored chapter: the parts of a chapter stay separate entries, each with its external_id. display_label is the chapter’s label in the target language (Chapitre 12 (partie 2), Prologue, Interlude – Ayla); the TXT, ZIP and bilingual EPUB results use it as the heading of a chapter whose title only gives its number or kind, and a ZIP of a volume with parts or specials numbers its files in reading order.
  • issues lists unresolved quality issues (segment_id, severity, code, message); flagged_passages lists passages still flagged (check, error or refused, not validated).
  • strategy names the provider and model only, never the provider’s address or key.
  • report is the completion report once the request ended, null before.

Completion report

The report appears in the status document (report), in the JSON result, as report.json inside the stored ZIP, and in the webhook.

{
  "version": 1, "outcome": "completed_with_residuals", "reason": null,
  "passages": {"total": 412, "translated": 410, "source_retained": 1, "untranslated": 1, "flagged": 3,
               "validated": 0, "human": 0,
               "by_status": {"ok": 407, "check": 3, "source_retained": 1, "error": 1}},
  "residual_total": 2,
  "residuals": [{"segment_id": "…", "chapter_id": "…", "chapter_external_id": null,
                 "chapter_title": "…", "position": 118, "status": "source_retained",
                 "kept": "source", "reason": "…"}],
  "residuals_truncated": false,
  "usage": {"calls": 1290, "prompt_tokens": 2410000, "completion_tokens": 610000,
            "cached_calls": 12, "cost": 3.41},
  "cost": {"estimated": 3.9, "actual": 3.41, "budget": 5.0, "book_spent": 4.62, "warning": null,
           "paused_for_budget": false, "provider_switches": 0},
  "durations": {"total_seconds": 5230.1, "queued_seconds": 0.4, "job_seconds": 5211.8},
  "autopilot": {"outcome": "completed_with_residuals", "rounds": 2, "reason": null},
  "decisions": {"autopilot": 17,
                "intake": [{"file": 2, "name": "notes.txt", "chapter_number": 3.0,
                            "confidence": "low", "reason": "…"}]},
  "chapter_map": [{"chapter_id": "…", "external_id": null, "status": "created", "kind": "afterword",
                   "number": null, "part": null, "part_count": null, "label": "", "position": 2,
                   "confidence": "high", "reason": "…"}],
  "delivery": {"validation": {"available": true, "valid": true}, "repairs": [], "inherited_errors": []},
  "quality": {"scored": 411, "average": 91.4, "minimum": 40, "to_review": 6, "review_below": 70,
              "bands": {"good": 380, "fair": 25, "weak": 5, "poor": 1},
              "histogram": [0, 0, 0, 0, 1, 2, 3, 10, 35, 360],
              "weakest_chapters": [{"chapter_id": "…", "title": "…", "external_id": null, "number": 12.0,
                                    "project_id": "…", "passages": 38, "scored": 38, "average": 78.2,
                                    "minimum": 40, "weak": 3, "…": "…"}],
              "review_first": [{"segment_id": "…", "chapter_id": "…", "chapter_title": "…",
                                "chapter_external_id": null, "position": 118, "score": 40, "band": "poor",
                                "signals": [{"code": "source_retained", "count": 1, "penalty": 60}],
                                "excerpt": "…", "…": "…"}]}
}
FieldMeaning
outcome, reasoncompleted, completed_with_residuals, failed or cancelled, and why when it did not complete.
passagesCounts over the request’s passages (the whole book for an EPUB).
residualsPassages delivered in their source text, at most 500 (residual_total counts them all, residuals_truncated says when the list is cut). They include a passage whose translation is still the source text (an open unchanged or untranslated alert its text still deserves), with the check’s words as its reason. reason is the autopilot’s when it recorded one, else the passage’s last error, else source_retained, untranslated, markup_mismatch or epubcheck_repair.
usageModel calls of the request’s job and their tokens. cost only counts calls with a known price, and is null when none had one.
costThe estimate made when the job started (null when none was made) against its real cost (usage.cost), the book’s budget (null without one), what the book has cost in all, the warning given at launch when the estimate exceeded what was left, whether the job was paused by a budget and how many times it moved to a cheaper provider for one.
durationsSeconds since the request was created, spent waiting for the volume, and spent in the job.
autopilotHow the autopilot ended (null when it did not run).
decisionsautopilot: the number of decisions the autopilot logged for the job; intake: the choices made when reading the upload (volume and chapter numbers, places of specials, text encoding, reused EPUB).
chapter_mapEach chapter of the request: chapter_id, external_id, status (created, unchanged, replaced), kind, number, part, part_count, label, reading position when imported, and the confidence and reason of the map (given or detected).
qualityQuality scores (0–100) of the request’s translated passages, computed from the signals Libris records (checks, critiques, doubts, failed calls, recoveries, retained originals; see the architecture): count, average, lowest, bands (good from 85, fair from 70, weak from 50, poor below), a ten-bucket histogram, to_review (below review_below and not validated by a person), the 10 weakest chapters and the 10 passages to review first with the signals that lowered them. null when the request has no volume.
deliveryEPUB only: EPUBCheck validation, the repairs made (attempt, errors, files, passages restored), inherited_errors, and errors when the delivery failed.

The full log of autopilot decisions for a book is shown in the interface (the book’s Autopilot tab); see the autopilot guide.

Webhooks

A request may name a callback_url. When it ends (any final status, and also imported), the worker sends one POST to that URL; with callback_events, it also sends one per batch of translated chapters before. The webhook is a convenience: the status document stays the reference, and you can always poll it.

Enabling webhooks (administrators)

Webhooks are off until an administrator allows at least one host. In Settings › Automation API › Webhooks of API requests, or with environment variables:

SettingEnvironment variableDefault
Allowed hosts (hooks.example.org, *.partner.example for its subdomains)API_WEBHOOK_HOSTS (comma-separated)empty: webhooks refused
Allowed private networks, in CIDR notationAPI_WEBHOOK_PRIVATE_NETWORKSempty
Attempts at mostAPI_WEBHOOK_MAX_ATTEMPTS6
Timeout of a call, in secondsAPI_WEBHOOK_TIMEOUT_SECONDS10
Global signing secret (32 characters at least)API_WEBHOOK_SECRETempty

Values saved in the interface win over the environment until Go back to the environment values. They apply without a restart. Every webhook must be signed: a token needs its own signing secret (chosen when the token is created) or a global secret must exist.

What is sent

{
  "event": "translation_request.finished",
  "request_id": "…", "external_id": "…",
  "status": "completed_with_residuals", "error": null,
  "project_id": "…", "job_id": "…",
  "status_url": "/api/v1/translation-requests/…",
  "result_url": "/api/v1/translation-requests/…/result",
  "artifact": {"format": "epub", "size": 812345, "sha256": "…"},
  "report": {"outcome": "completed_with_residuals", "residual_total": 2, "…": "…"},
  "finished_at": 1790000000.0
}
HeaderValue
X-Libris-Eventtranslation_request.finished
X-Libris-Delivery<request id>:<attempt number>
X-Libris-TimestampUnix time in seconds
X-Libris-Signaturesha256=<hex>: HMAC-SHA256 of <timestamp>.<body>
User-AgentLibris-Webhook/1

The signature uses the token’s own webhook secret when it has one, otherwise the global secret.

Verifying a webhook

Check the signature over the raw body, and refuse old timestamps to block replays (the example client does the same in verify_signature and serves it with its webhooks command):

import hashlib
import hmac
import time


def verify(secret: str, body: bytes, timestamp: str, signature: str, tolerance: int = 300) -> bool:
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, "sha256=" + expected)

Retries

Any 2xx answer counts as delivered. Anything else (another status, a timeout, a network error) is retried with an exponential backoff: 30 seconds, then 60, 120… up to one hour between attempts, for at most API_WEBHOOK_MAX_ATTEMPTS calls. Redirects are not followed. The status document shows the webhook’s state, attempts and last error.

Protections

A callback_url that breaks one of these rules is refused at once with 422 callback_refused, before anything is stored:

  • it must be an http or https URL without user name or password, of at most 2000 characters;
  • its host must be in the allowed list;
  • a signing secret must exist (the token’s or the global one);
  • its name must resolve only to public addresses. Loopback, private, link-local, reserved and multicast addresses are refused unless they fall inside an allowed private network.

The name is resolved again before every call, and the call goes to the address just checked (with the original name in the Host header and the TLS server name), so a DNS answer that changes in between cannot redirect it. Proxy environment variables are ignored.

Batches of translated chapters

A request whose callback_events contains chapters.translated also gets one webhook per batch of chapters whose passages all have a translation, while the job runs: a client can publish chapters one batch at a time instead of waiting for the whole request. Chapters already translated when the request was imported are not announced. The request’s final translation_request.finished webhook is sent as usual.

{
  "event": "chapters.translated",
  "request_id": "…", "external_id": "…", "series_id": "…", "project_id": "…",
  "batch": 2,
  "chapters": [{"chapter_id": "…", "external_id": "chapter-052", "number": 52, "title": "Chapter 52"}],
  "announced": 2, "total": 3,
  "status_url": "/api/v1/translation-requests/…",
  "result_url": "/api/v1/translation-requests/…/result?partial=true",
  "created_at": 1790000000.0
}

batch counts from 1 per request; announced is the number of chapters announced so far, total the number of chapters in the request. Batches use the same allowed hosts, signature, headers and retries as the final webhook, with X-Libris-Event: chapters.translated and X-Libris-Delivery: <request id>:chapters.translated:<batch>:<attempt>. They are sent before the final webhook when both are due, but a batch that is retried can arrive after it: order them by batch. A batch is a draft: until the request ends, the final review may still improve its chapters. Fetch them with ?partial=true&scope=new (or format=json, which gives each chapter with its chapter_id); the final result stays the reference.

List your series

curl -sS "$LIBRIS_URL/api/v1/series" -H "Authorization: Bearer $LIBRIS_TOKEN"
curl -sS "$LIBRIS_URL/api/v1/series/$SERIES_ID" -H "Authorization: Bearer $LIBRIS_TOKEN"

The list is sorted by name and gives id, name, kind, source_language, target_language, archived, volumes, created_at, updated_at. The detail adds volume_list, in the series’ reading order, with project_id, title, volume_number, volume_label, external_id, source_format, project_kind, status, chapters. Only series the token’s owner owns are visible; volumes shared with them are not.

A special volume (« DX1 », « EX », « LN 14+ ») has no volume_number; a person may give it a free volume_label instead (the result’s volume.label). A label that starts with a number (14+, LN 14++, Vol. 3.5) places the volume right after that number. Vol. 0 and Vol. 0.5 place a prequel before volume 1, with EPUB series position 0.5; any other label places it at the end of the series; a volume with neither stays outside the reading order. That order decides which volumes a volume waits for and which series memory it receives.

Shared glossaries

A shared glossary is the terminology of a universe common to several of your series (places, titles, spells…). Each series follows at most one; its accepted terms reach every volume of the series, after the book’s and the series’ own terms: book > series > shared glossary. A locked shared term is enforced and checked in every passage, and beats an unlocked term an earlier volume proposed; a person’s series decision or a volume’s deliberate override always wins. The same glossaries are managed in the interface (Glossaires partagés).

# Create one; languages are optional (with them, it only applies to volumes of the same pair).
curl -sS "$LIBRIS_URL/api/v1/glossaries" -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Glass Road universe", "source_language": "en", "target_language": "fr"}'

# Preview an import, then apply it.
curl -sS "$LIBRIS_URL/api/v1/glossaries/$GLOSSARY_ID/import?dry_run=true" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -F file=@universe.csv -F strategy=replace
curl -sS "$LIBRIS_URL/api/v1/glossaries/$GLOSSARY_ID/import" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -F file=@universe.csv -F strategy=replace

# Make a series follow it (send {"glossary_id": null} to detach).
curl -sS -X PUT "$LIBRIS_URL/api/v1/series/$SERIES_ID/shared-glossary" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -H "Content-Type: application/json" \
  -d "{\"glossary_id\": \"$GLOSSARY_ID\"}"

# Download it; for spreadsheets: CSV with semicolons and a byte order mark.
curl -sS "$LIBRIS_URL/api/v1/glossaries/$GLOSSARY_ID/export/csv?delimiter=semicolon&bom=true" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" -o universe.csv

A glossary gives id, name, description, source_language, target_language, created_at, updated_at, term_count, locked_count, series (the series that follow it, id and name) and, for one glossary, terms with id, source, translation, category, description, locked, accepted. Only accepted terms are applied.

Export. GET …/export/{format} takes json, csv or tbx. For CSV, delimiter is comma (default), semicolon or tab, and bom=true adds a UTF-8 byte order mark (default false); delimiter=semicolon&bom=true opens directly in Excel.

Files. JSON (a list of terms with the fields above), CSV or TBX (v2 and v3). CSV files may carry a byte order mark or come from Excel on Windows; the separator (;, , or tab) is detected, and headers are recognised in French or English (source, terme source, traduction, target, catégorie, notes, verrouillé, accepté…); a file without header uses its first two columns. Form fields of an import:

FieldValuesDefault
fileThe glossary file (2 MB at most)required
strategyskip keeps terms in place; replace replaces unlocked terms that differ; replace_all replaces locked ones tooskip
delimitersemicolon, comma or tabdetected
mappingJSON object from field to column number (from 0), for example {"source": 0, "translation": 2}from the headers
headertrue or false: whether the first row holds column namesdetected
skip_invalidtrue leaves invalid rows out instead of refusing the filefalse

The answer is the import report: format, encoding, delimiter, columns (the first row), header, mapping, strategy, counts (terms, new, unchanged, conflicts, replaced, kept, duplicates, errors), the lists new, conflicts (with existing, incoming, the differing fields, locked and the action replace or keep), duplicates (a source repeated in the file: the first row counts) and errors (line, message), each cut to 200 items (truncated), and applied. An applied import adds imported, replaced and skipped. Sources are matched case-insensitively. A preview (dry_run=true) lists invalid rows instead of refusing the file.

Account administration (session API)

Account management is deliberately separate from automation. The following routes require an administrator’s authenticated session cookie and the interface’s same-origin protections; an automation bearer token does not grant these rights. They are under /api, not /api/v1, and are not part of the automation OpenAPI document. The interface exposes them in Settings › Users.

Method and pathBody and result
GET /api/usersList accounts, including id, username, email, admin, active and sso_subject. A non-empty sso_subject identifies an SSO-linked account. Password hashes and authentication secrets are never returned.
GET /api/users/seats{used, allowed, refusal}: active accounts, the licence allowance and a refusal message or null. allowed: 0 means the licence supplies no account limit.
POST /api/users{username, password, email} creates a local account and returns its view with 201. email is optional and defaults to "". This does not send an invitation or an initial password by email.
PUT /api/users/{id}Partial update: any of username, email, admin, active. Omitted or null fields stay unchanged; email: "" removes a local account’s address. Unknown fields are refused. Returns the updated account and revokes its sessions.
PUT /api/users/{id}/password{password} resets a local password, invalidates previously issued recovery links and revokes the account’s sessions. Returns {ok: true}.
DELETE /api/users/{id}/second-factorRemoves Libris’ own second factor for account recovery and revokes its sessions. It does not change the SSO provider’s authentication policy.
DELETE /api/users/{id}?transfer_to={recipient_id}Deletes another local account after the checks below; returns {deleted: true}. transfer_to is required when the account owns books, series or shared glossaries.

A username is 2–80 characters: letters, digits, _, ., @ or -. Passwords are 12–200 characters. An email address is at most 320 characters and is stored in normalized lowercase form. A new address cannot duplicate another local account’s address, ignoring case; an empty address is allowed. Existing duplicate addresses are not rewritten automatically. Recovery by a shared legacy address does not pick an arbitrary account: use its unique username instead. Changing an address invalidates recovery links sent to the previous address.

SSO-linked accounts keep their identity and credentials at the identity provider. Local administrators may change admin and active, but editing username or email, resetting a password, or deleting the account locally returns 409. An old local password cannot be used as an alternate login for an SSO-linked identity. Account creation here never links a local account to an SSO identity by matching email addresses.

Directory-linked accounts (ldap_subject non-empty, see ldap.md) follow the same rule for username, email, passwords and deletion, but keep Libris’ second step: an administrator may still remove their factors, and they confirm factor changes and recent proofs with their directory password. When an administrator group is configured, changing admin on such an account returns 409.

Directory routeContract
GET /api/auth/ldapPublic. {enabled, label} for the sign-in screen.
POST /api/auth/ldap/login{username, password}. Same answers as POST /api/auth/login (a session, or {second_factor, token, username}); 401 “Identifiants incorrects.” for an unknown login, several matching entries, a wrong or empty password; 403 for a person outside the allowed groups, an unknown person when account creation is off, or a deactivated account; 402 seats_exceeded; 503 when the directory cannot be reached. Throttled with the local sign-in, under the same name.
GET / PUT /api/settings/ldapAdministrator. The settings of configuration.md; bind_password is write-only (null keeps it) and the answer only says has_bind_password. PUT requires a recent proof and refuses plain ldap:// without StartTLS or an explicit allow_plaintext.
POST /api/settings/ldap/testAdministrator. {username?}: binds with the service account and, when a login is given, answers the entry the filter finds (dn, username, email). No user password is tried.
POST /api/users/{id}/directoryAdministrator, recent proof. {login?} (default: the account’s username): links a local account to its directory entry, forgets its local password, recovery links and sessions, and keeps its books, factors and tokens. 409 if already linked or if the entry belongs to another account.

Recent proof for sensitive account changes

All account mutations above, and changes to the current local account’s recovery email through PUT /api/auth/notifications, require a proof no older than 300 seconds, attached to the current session. Read-only listing and notification/delivery preferences do not. An expired or missing proof returns 403 with {detail: {code: "reauth_required", message, methods}}. Bearer tokens cannot provide this proof. Existing sessions start without one; a newly completed local login counts when its required factors were verified. WebAuthn only counts if the signed assertion proves user verification (UV). An ordinary OIDC login never silently elevates the session.

Session routeContract
GET /api/auth/reauth{valid_until, methods, code_required}; methods contains usable password, key, or sso. No other account’s factors are exposed.
POST /api/auth/reauth/password{password, code}; the existing TOTP or single-use recovery code is required when a second factor is active. Refuses SSO identities.
POST /api/auth/reauth/key/beginReturns {token, options} for an existing key, with userVerification: "required". The challenge belongs to this session and expires after five minutes.
POST /api/auth/reauth/key{token, credential}; verifies signature, RP, origin, ownership, counter and UV, then consumes the challenge once.
POST /api/auth/reauth/sso/beginReturns {token, url} with fresh state/nonce/PKCE, prompt=login and max_age=0; use a separate window, preserving the original session and draft.
POST /api/auth/reauth/sso/complete{token} consumes the provider-verified request from the original session only; returns {valid_until}.

The existing OIDC callback distinguishes a confirmation from a login. A confirmation verifies the signature and a fresh, finite numeric auth_time, pinned issuer/client/subject and nonce, then marks only the request as verified. It does not create an account or open a session, and works without the SameSite=Strict cookie omitted by a cross-site redirect. The original same-origin window must finish the request. Provider refusal, missing/stale auth_time, mismatched identity, expiry or replay refuses elevation; there is no silent SSO fallback. Failed confirmations do not retry account mutations.

The interface retries only the single refused HTTP request, at most once. Cancelling retains its draft. This policy is scoped to account changes; it does not claim to protect every SMTP, licence or provider-secret setting in the installation. Personal factor/password operations retain their own existing password/code checks.

Deletion and transfer safeguards

Deleting an account is not the same as disabling it. Disabled accounts retain their library; deletion removes sessions, API tokens and review links created by the departing account. An explicit transfer preserves books, series and shared glossaries. Translation history keeps the text, with the departed author anonymized. The recipient must be another active account. A conflicting series or shared-glossary name in the recipient’s library is refused rather than merged or overwritten.

Deletion also refuses jobs that can still engage provider spending: pending, waiting, analyzing, translating, reviewing or syncing. Personal provider credentials are not transferred with a book: an account with a personal provider that still has an API key, or a personal codex_chatgpt provider whose OAuth credentials live in the adapter, cannot be deleted. Where the transfer is allowed without those credentials, personal provider selections are removed from the transferred books and series; choose an authorized provider before resuming work.

Before deleting such an account, permanently retire its personal providers in Settings › LLM providers. POST /api/providers/{id}/retire takes {"confirm":true} (a literal boolean), clears the stored key, and keeps the provider row and all historical costs. A Codex provider must successfully log out through its bridge and then report connected:false; otherwise retirement returns 502 and account deletion remains refused. Active jobs, unexpired worker leases and running model calls refuse retirement (409). Pause work and wait for its actual termination first.

Retirement cannot be undone; create a new provider to reconnect later. Retired providers reject editing, model tests, Codex login and inference admission, including a request that loaded the provider before retirement. The regular provider list excludes them; GET /api/providers?include_retired=true includes history rows for their owner or administrators. API keys stored elsewhere are not revoked at the external provider: revoke those separately when required. Never bypass a refusal through direct database deletion. A downgrade removes the retirement marker: do not roll back to an older application against a database containing retired providers without restoring its matching pre-upgrade backup.

The main refusals are:

  • 401: no valid session; 403: the caller is not an administrator.
  • 404: the account does not exist.
  • 402: creating or reactivating an account would exceed the licence’s account allowance.
  • 409: duplicate local identity, protected SSO identity, an attempt to remove one’s own administrator access, missing or conflicting library transfer, active work, or personal provider credentials.
  • 422: invalid fields, or an invalid/inactive transfer recipient when a transfer is required.

An administrator cannot delete their own account through /api/users/{id}. The separate My account departure flow asks for the current password and keeps the last active administrator in place.

Nextcloud / WebDAV connections (session API)

The interface’s routes behind My account › Nextcloud / WebDAV connections, the import wizard’s From Nextcloud/WebDAV and a book’s Send to Nextcloud/WebDAV. They need a signed-in session (not an automation token), are under /api, not /api/v1, and exist only when the administrator set WEBDAV_HOSTS (configuration.md). A connection belongs to its account: another account, administrators included, gets 404.

Method and pathBody and result
GET /api/webdav/connections{enabled, hosts, connections}; each connection is {id, name, url, username, has_password, password_readable, automatic, automatic_folder, automatic_format, created_at, updated_at}. The password itself is never returned.
POST /api/webdav/connections{name, url, username, password, automatic, automatic_folder, automatic_format} → 201 and the connection. url must name an allowed host, over HTTPS (HTTP only inside WEBDAV_PRIVATE_NETWORKS), without credentials, query or fragment. automatic (default false) sends every book the account owns to automatic_folder (relative to the connection, default its root) in automatic_format (same values as a sending, default epub) when its translation or review finishes.
PUT /api/webdav/connections/{id}Same body; an absent password keeps the saved one, except when the server or the username changes (422: type it again).
DELETE /api/webdav/connections/{id}Deletes the connection and its pending sendings.
POST /api/webdav/connections/{id}/testPROPFIND (depth 0) of the address: {ok: true}, or the refusal.
GET /api/webdav/connections/{id}/browse?path=Livres{path, parent, entries: [{name, path, folder, size, modified}]}: the direct children of a folder, folders first. Paths are relative to the connection’s address and never climb above it.
POST /api/imports/{session_id}/webdav{connection_id, path}: reads the file (at most MAX_UPLOAD_MB) into the import session, with the same checks and the same answer as POST /api/imports/{session_id}/files. A file of another format is refused before it is downloaded.
GET /api/projects/{id}/webdav-publicationsThe sendings of this volume to the caller’s connections: {id, connection_id, connection_name, folder, format, status, attempts, next_attempt, error_code, filename, published_at, created_at}.
POST /api/projects/{id}/webdav-publications{connection_id, folder, format} → 202: queues the finished volume for the worker. format is epub, epub-bilingual, docx (without revisions), txt, txt-zip or md. Anyone who can read the volume may send it to their own connections.

A sending moves from pending to published or failed, with the library publication’s retries (30 s, doubling up to an hour, eight attempts). error_code says why it waits or failed: incomplete, unreachable, server_error, build_failed are retried; unauthorized, secret_unreadable, refused, not_found, name_taken, too_large and access_lost fail at once. A missing destination folder is created with MKCOL before not_found is given up on. Errors of these routes are never 401 or 403 — those belong to the Libris session: a password the WebDAV server refuses is a 422, an unreachable server a 502, an answer larger than allowed a 413.

Errors

Every error has the same shape:

{"detail": {"code": "chapter_conflict", "message": "…", "conflicts": ["…"]}}

code is stable; message is in French by default and in English with Accept-Language: en. Validation errors never echo the submitted values, so book text is never sent back.

HTTPcodeWhen
401missing_token, invalid_token, revoked_token, expired_token, inactive_accountNo token, or a bad one (header WWW-Authenticate: Bearer).
401unauthorizedA body above 1 MiB sent without an Authorization: Bearer header, refused before it is read. A smaller request without a token gets missing_token.
402automation_not_licensedThe licence of the installation does not include the automation API (Trial and Personal plans; Studio and Pro include it). Existing tokens are kept and work again once the licence includes it. Answered after the token is checked: a missing or wrong token still gets its 401.
402budget_exceeded (with budget)The token’s cost budget is reached: a request that would start work is refused (see Token budget).
403insufficient_scope (with scope)The token lacks a permission.
403forbiddenA browser request from another site (see below).
403priority_not_allowed (with max_priority)The priority asked for is above the token’s or the account’s ceiling.
404request_not_found, series_not_found, volume_not_found, glossary_not_found, not_foundUnknown, or owned by someone else.
409idempotency_conflict (with request_id)Same key or external_id, different content.
409chapter_conflict (with conflicts)Chapters exist with another text; send replace_changed_chapters.
409conflict (with protected_segments)A replacement would drop human edits; send discard_human.
409volume_conflict, series_archived, volume_archivedThe target volume cannot take this content.
409result_not_ready, request_failed, request_cancelled, format_unavailableThe result cannot be served (see Get the result).
409not_started, conflictPause, resume or cancel not allowed in the current state (see Pause, resume or cancel).
409glossary_exists, language_mismatchA shared glossary of that name exists; its languages differ from the series’.
409budget_exceededResuming a job paused by a book or token budget that is still reached.
413payload_too_large, glossary_too_largeThe body is above the size limit.
415unsupported_media_typeNeither JSON, EPUB nor multipart.
422invalid_payload (with errors: [{loc, msg, type}])The document or the upload is invalid.
422invalid_request (with errors)A bad query parameter (for example format, wait).
422invalid_idempotency_key, unknown_provider, provider_required, invalid_epub, fixed_layout_epub, callback_refused, delivery_failedSee the sections above.
422invalid_placementA chapter’s after names no chapter of the volume or of the request.
422invalid_glossary, invalid_strategy, invalid_mapping, invalid_nameThe glossary file, its import options or the glossary name (see Shared glossaries).
429rate_limitedToo many calls for this token (header Retry-After).
429queue_full (with scope, limit)The token or the account already has its quota of requests waiting, on a new request or a resume (see Queue priority and quotas). No Retry-After: retry once a waiting job has started.
500server_errorUnexpected failure; the message carries a diagnostic reference for the server logs.

Server-to-server clients do not send an Origin header and are accepted. A browser page on another site is refused like for the interface (ALLOWED_ORIGINS, Sec-Fetch-Site).

Limits

SettingDefaultEffect
API_MAX_PAYLOAD_MBMAX_UPLOAD_MB (256)Largest request body with a Bearer token: an EPUB, or all TXT files together. Enforced before the body is read. Without a Bearer header the limit is 1 MiB and the answer 401.
API_MAX_CHAPTERS2000Chapters (or TXT files) per request.
TEXT_CHAPTER_MAX_CHARS2,000,000Characters per chapter, shared with TXT imports.
API_RATE_LIMIT_PER_MINUTE120Calls per token over a sliding minute, counted in each API process (with several API replicas the effective limit is multiplied). 0 disables it.
API_RESULT_MAX_WAIT_SECONDS60Longest ?wait= (0–600).
API_REQUEST_STALL_MINUTES360A request whose job stays paused, blocked or waiting longer fails, and the job is cancelled (a wait for an earlier volume’s analysis is bounded separately).
API_REQUEST_MAX_HOURS168A request still unfinished after this fails, and the job is cancelled.
DELIVERY_REPAIR_ATTEMPTS3EPUBCheck repair rounds of a delivered EPUB.
RETENTION_RESULTS_DAYS30Days a stored result file is kept (it can be rendered again afterwards).
API_WEBHOOK_*see WebhooksWebhook hosts, networks, secret, attempts and timeout.
QUEUE_*see configurationJobs per account running and waiting, priority aging; a token can have lower limits of its own.

An EPUB is also bounded by the archive limits of every import (MAX_UNPACKED_MB, MAX_ENTRIES, MAX_COMPRESSION_RATIO). All settings are described in the configuration reference.

A complete example

Upload an EPUB, wait for the end, then download the translated book and read the report:

#!/usr/bin/env bash
set -euo pipefail

REQUEST_ID=$(curl -sS -X POST "$LIBRIS_URL/api/v1/translation-requests" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" \
  -H "Idempotency-Key: book-fr-1" \
  -F "file=@book.epub" -F target_language=fr -F provider_id="$PROVIDER_ID" \
  | jq -r .request_id)

while :; do   # each call answers when the request ends, or after 60 seconds
  STATUS=$(curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID?wait=60" \
    -H "Authorization: Bearer $LIBRIS_TOKEN" | jq -r .status)
  case "$STATUS" in completed|completed_with_residuals|failed|cancelled) break ;; esac
done
echo "Request ended: $STATUS"

if [ "$STATUS" = completed ] || [ "$STATUS" = completed_with_residuals ]; then
  curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID/result" \
    -H "Authorization: Bearer $LIBRIS_TOKEN" -o book.fr.epub
fi
curl -sS "$LIBRIS_URL/api/v1/translation-requests/$REQUEST_ID" \
  -H "Authorization: Bearer $LIBRIS_TOKEN" | jq '.report | {outcome, residual_total, usage}'

Proofreading links are separate from /api/v1: they authorize reading one volume without an account, not automation. The shared address remains /#review/<secret>; the fragment is not sent to the server. The reader sends Authorization: Bearer <secret> to these two endpoints:

EndpointBounded pageContinuation
GET /api/review/book?offset=0100 chapter headings, plus title, author, target language and the link’s label/source visibilitynext_offset, or null at the end
GET /api/review/chapters/{chapter_id}?offset=02,000 translated passages, as an export gives them; original text only when explicitly allowed by the linknext_offset, or null at the end

Offsets must be integers between 0 and 2,147,483,647. A missing, expired, revoked or unknown credential returns the same 404; another volume’s chapter cannot be read. Session cookies never replace the link. A real link on an installation whose licence does not include sharing (only Studio and Pro do) answers 402 with code: "sharing_not_licensed": the link is suspended, not deleted, and opens again once the licence grants sharing. Creating a link or inviting a member is refused the same way. All API responses, including refusals, use Cache-Control: no-store. Revocation is checked on every page; text already read or copied cannot be recalled.

Reads are limited before any SQL to 120 per client IP and 600 in total over a sliding minute, per API process. A 429 includes Retry-After; retries with different secrets share the same limits. A reverse proxy must forward the real client address through the installation’s trusted-proxy configuration. The visit counter groups table-of-contents openings into at most one atomic increment per link per minute; it is an activity indicator, not an exact visitor count.

The 0.10.0 reader put the secret in API request paths. Those old paths are no longer served, while already-issued browser links still open with the updated reader. If historical access logs were shared or exposed, revoke the affected links and create replacements: this update cannot erase old logs.

Integrated reader (interface API)

The signed-in interface uses GET /api/projects/{pid}/read/{chapter_id}, not /api/v1. A session and access to the volume are required; API tokens and public proofreading links do not grant access. A chapter belonging to another volume is refused with 404.

offset starts at zero and is bounded to 2,147,483,647; limit defaults to 100 and accepts 1–250. The response carries chapter, can_edit, passages and next_offset (null at the end). Each passage contains id, revision, translation, retained_source and ordered units with their identifiers, translated text and protected EPUB markers. translation is the text an export gives — a machine passage in the typography of the target language, even one translated before 0.18 — while units carry the stored text a correction starts from, as in the editor. source=true additionally returns the source text; import metadata and analysis are never included. An explicitly retained original remains the displayed text and is identified by retained_source, not presented as a translation.

The reader adds no write endpoint. Corrections use the existing PUT /api/segments/{sid} with revision and the complete ordered units array (id, text). Read-only collaborators are refused by that endpoint. A stale revision returns 409 rather than overwriting a concurrent correction. Successful corrections enter the same human-version history as edits in the side-by-side editor.

Starting a whole series

POST /api/v1/series/{series_id}/jobs/start runs the whole pipeline on every volume of a series in one request. It needs jobs:control and pipeline:start: control stops a series, only the second may bill one. The browser uses POST /api/series/{id}/jobs/start.

The report is the same best-effort shape as the other series actions — one line per volume with done, jobs, and a code/message when refused. A volume already working answers already_running and is left alone; archived volumes are not included at all; a suspended series refuses the whole call with series_suspended rather than quietly resuming itself — exactly like the series page’s own button, which never shows a launch action while paused, only Reprendre la série. A caller that also carries ?resume_if_paused=true lifts the pause and launches in the same request; off by default, and the interface never sets it — ask for it only where undoing the pause in the same call is the actual, deliberate intention (automation, not a person’s click).

Persistent suspension of a series

POST /api/v1/series/{series_id}/jobs/{action} requires jobs:control. The token’s account must own the series; access to a shared volume is insufficient. action is pause, resume or cancel. No request body is required.

Pause records paused_at on the series and pauses its held jobs in one server request. It prevents new paid work, including source-watch autostarts. Imported chapters remain available; reading, manual corrections and exports are unaffected. Provider calls already sent may still finish. Resume clears paused_at first, then applies the usual job controls to each volume. Cancel does not change paused_at and does not prevent future source-watch launches by itself.

The response is HTTP 200 for a valid series action even when individual volumes refuse:

{
  "series_id": "...", "action": "resume", "paused_at": null,
  "done": 2, "refused": 1, "deferred": 1,
  "volumes": [
    {"project_id": "one", "title": "Volume 1", "jobs": 1, "done": true},
    {"project_id": "two", "title": "Volume 2", "jobs": 0, "done": false,
     "code": "budget_exceeded", "message": "Book budget reached (...)"},
    {"project_id": "three", "title": "Volume 3", "jobs": 1, "done": true,
     "deferred": true, "message": "Resume scheduled: the job is waiting for a slot in the fair queue."}
  ]
}

done counts volumes where the command succeeded (including volumes with no eligible job), not running jobs. jobs counts changes committed for that volume. deferred is a subset of done: the queue was full, so its resumption is stored and will be claimed automatically under the normal running quotas. The queue view includes these resumptions. No token may resume paid work started by another token or by a browser session. Licence, account allowance and budget checks run again at resumption regardless of the previous stop reason. HTTP 401/403/404 still reject authentication, scope or series ownership before any change. Outcome messages follow Accept-Language.

The browser uses POST /api/series/{id}/jobs/{action} for a durable series suspension. Its separate POST /api/projects/batch/jobs/{action} accepts a project_ids array (1–500) and optional operations filter; this controls only the selected jobs, never the persistent suspension of an entire series.

Series archives (session API)

A whole series moves between installations as one file. These are session routes (the interface’s), not /api/v1: the project archive of a single volume is not part of the automation API or of MCP either.

  • GET /api/series/{id}/export — owner of the series only (404 otherwise). Answers application/zip: series.json and one project archive per volume (volumes/<n>.zip). Refused with 413 when the import could not read it back (MAX_UPLOAD_MB, MAX_ENTRIES, or a volume over its own limits, named in the message), and 409 when a volume’s source files are missing on the server.
  • POST /api/series/import — multipart field file. Creates a new series of the caller and answers 201 with id, name, renamed_from (the archive’s name when it was already taken and the series became Name (2)…, otherwise null) and volumes (the new project ids, in the archive’s order). Everything is checked before anything is written: 422 names the incorrect field (links.0.entity_id, entities.3.merged_into_id…) or the volume and its problem; 409 when a volume’s EPUB is already in the caller’s library; 413 above MAX_UPLOAD_MB. A volume archive sent here, or a series archive sent to POST /api/projects/import, is refused with a message naming the right route.

Providers, members, the series suspension, the audit log, the attached shared glossary, automation requests and OpenViking documents do not travel; interrupted jobs come back paused. Format and checks: architecture.md.

Provider batches and estimates

Provider create/update session routes accept a capabilities.batch object:

{"enabled": true, "kind": "openai", "max_wait_hours": 24}

kind is openai or anthropic and must match the provider transport. ChatGPT account connections cannot enable batches. enabled is a strict boolean; max_wait_hours is an integer from 1 to 24. It is Libris’s local cancellation-request deadline, not a provider service-level guarantee. OpenAI’s submitted completion window remains 24h. Dependent steps may require several batches.

Enter synchronous per-million-token prices, not discounted prices. Job forecasts (including estimate_job) and pre-import estimates use half those rates when batch mode is enabled. Their batch object exposes enabled and, when enabled, max_wait_hours; currency_note explains the assumption and the normal-rate synchronous fallback. Token counts are unchanged. Actual request logs record the rates at submission, not whichever rates are configured when results arrive.

GET /api/projects/{pid}/batch is a read-only session route with the book’s usual read access. It returns null when the latest job has no batch summary, otherwise:

{"job_id": "...", "status": "waiting", "batch": {
  "state": "submitted", "pending": 412, "collected": 500,
  "submitted_at": 1790000000, "next_poll_at": 1790000300
}}

All batch fields except state are optional during transitions. States are collecting, uploading, submitting, submitted, canceling, ready, unknown, unsupported. The summary may also contain provider_id, submission_key, remote_id, last_polled_at, poll_attempts and a safe message. It never returns the raw checkpoint, API credentials, collected prompts or result bodies. Counts are requests, not unique segments. HTTP 401/404 protect authentication and cross-account isolation.

unknown means acceptance needs checking, not that an automatic resubmission is safe. unsupported announces the current job’s full-price synchronous fallback after a missing Batch API. Cancellation is a provider request; already-completed calls may remain billable. Polling already-paid results continues outside the configured work window; new submissions still cross spending guards.

POST /api/projects/{pid}/jobs/{jid}/batch/reconcile is a session route restricted to the book owner. It accepts {"remote_id":"...","submission_key":"...","confirm":true}; confirmation is a literal boolean. It reconnects an unknown submission to a batch found in the provider dashboard, never creates another batch. OpenAI must echo the unique submission metadata and uploaded file ID. Anthropic must have ended and expose exactly the expected result IDs. A foreign batch or unfinished Anthropic batch returns 409; provider verification failures return 502. The job’s human pause/cancel state is preserved while background reconciliation resumes. Never discard the pending request rows or remove the provider’s credentials to get around this check: they retain the outstanding bill.

Collection is bounded to 500 requests and 20 MiB per wave; result streams are bounded to 64 MiB. Known transient member failures have at most three remote attempts; successful neighbours are not resubmitted. An unacknowledged POST and a completed batch missing a result never trigger a blind paid retry. Request retention preserves pending commitments and results needed by their job state.

SMTP administration (session API)

These installation routes require an administrator session, not an automation token. They are intentionally outside /api/v1 and its OpenAPI contract.

RoutePurpose
GET /api/settings/mailEffective settings, saved, configured, has_password; never a password
PUT /api/settings/mailComplete override: enabled, host, port, starttls, username, sender, timeout, optional write-only password
DELETE /api/settings/mailRestore the installation’s environment values
POST /api/settings/mail/test{ "recipient": "reader@example.com" }; 202 with queued and message id
GET /api/settings/mail/outboxOptional `status=pending
POST /api/settings/mail/outbox/{id}/retryRequeue a failed message, except password recovery links

An omitted/null password is preserved; an explicit empty string clears it. Outbox rows expose only recipient, subject, kind, delivery state/times, attempts and a safe error code/message. Bodies, contexts and raw SMTP replies are excluded. Configuration, test and retry actions are audited without credentials. SMTP traffic happens in the worker, never inside the HTTP test request. sending means a worker owns a renewable delivery lease; its internal ownership token is never returned. next_attempt then denotes lease expiry, not a scheduled second send. Retry remains restricted to failed messages and resets their attempt budget without reusing an old lease.

AI-assisted translation notice (session API)

Every EPUB Libris writes carries a machine-readable “AI-assisted translation” notice (#284). It is on by default and follows two switches, the book’s first:

  • Installation (administrator): GET / PUT /api/settings/exports, body and answer {"ai_disclosure": bool} (absent = true). Stored in app_settings (key exports), no schema change.
  • Book (writer of the volume): PATCH /api/projects/{pid}/edition with {"ai_disclosure": true | false | null}; null goes back to the installation’s choice.
  • GET and PATCH /api/projects/{pid}/edition answer ai_disclosure (the value the next export uses: the book’s choice, else the installation’s), ai_disclosure_default (the installation’s) and ai_disclosure_book (the book’s own choice, null = follows the installation). An interface shows ai_disclosure, and writes it only when the user flips the switch: a book never touched keeps following the installation.

The effective value applies to every EPUB path: GET /api/projects/{pid}/export/epub and epub-bilingual (including chapter ranges), POST /api/exports/epub, API v1 requests, library publication, WebDAV and e-mail delivery. Changing a switch affects the next EPUB written, not the files already delivered.

Library publication (session API)

These routes require a session; automation tokens do not grant access. An installation administrator uses GET, PUT or DELETE /api/settings/library. PUT accepts enabled, directory, automatic and series_folders; DELETE restores DELIVERY_DIR. The directory must already be mounted in the worker. Publication uses that worker’s filesystem, not an HTTP request to a library.

POST /api/projects/{pid}/publish is owner-only and returns 202 with the durable publication state. Repeated requests while pending coalesce. GET /api/projects/{pid}/publication is available to readable-volume users and returns enabled, id, status (none, pending, published, failed), attempts, next_attempt, error_code, filename and published_at. No server path or raw exception is returned. A failed publication may be queued again through POST. Eight attempts are allowed; worker restarts recover expired five-minute leases. These routes neither start nor charge a model.

Changing the configured destination refuses an older pending destination with configuration_changed; an explicit retry selects the new folder. published proves atomic file delivery, not downstream indexing. Readers must configure a Komga/Kavita scan or a separate Calibre import. See the publication guide.

Published EPUBs carry the current Libris series name and optional volume number through EPUB 3 belongs-to-collection, collection-type=series and group-position (for a special volume labelled after a number, such as 14+, the position is 14.5; a label placed at the end of the series writes no position). The volume title and stable UUID filesystem paths stay separate. EPUB 2 sources are upgraded using the existing resource-preserving conversion and validation gate. Changed metadata requires republication and a reader scan; a reader’s internal grouping IDs and reading progress are outside Libris’s control.

First steps (session API)

The first-launch checklist is read from what the installation already holds; nothing is asked of the licence server. Both routes require an administrator’s session; a member receives 403.

Method and pathBody and result
GET /api/onboarding{steps, providers_priced, completed, dismissed, visible}. steps lists, in order, {key, done} for licence (a signed certificate is held), provider (a provider that is not retired), provider_tested (a successful POST /api/providers/{id}/test, or any model call already made), first_book (a book exists) and admin_password (the account BOOTSTRAP_USERNAME no longer has BOOTSTRAP_PASSWORD). providers_priced is false when no provider has an input or output price: import estimates will then show 0.
PUT /api/onboarding{dismissed: true} hides the list, {dismissed: false} shows it again. Returns the same view.

visible is true until every step is done or the list is dismissed. It is only a hint for the interface: no step blocks anything.