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 Acceptedanswer, 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) orcancelled. It never staysrunningforever.
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 path | Scope | Purpose |
|---|---|---|
POST /api/v1/translation-requests | content:write (+ pipeline:start to translate) | Send an EPUB, TXT or DOCX chapters or a JSON document |
GET /api/v1/translation-requests/{id} | jobs:read | Status, progress and report (?wait= to long-poll) |
POST /api/v1/translation-requests/{id}/pause | jobs:control | Pause the request’s job |
POST /api/v1/translation-requests/{id}/resume | jobs:control | Resume it |
POST /api/v1/translation-requests/{id}/cancel | jobs:control | Cancel it |
GET /api/v1/translation-requests/{id}/result | results:read | Download 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/providers | content:write | List the providers a request may use |
GET /api/v1/series | series:read | List your series |
GET /api/v1/series/{id} | series:read | One series and its volumes |
POST /api/v1/series/{id}/jobs/{action} | jobs:control | Suspend/resume/cancel a series with a per-volume report; see Persistent suspension of a series |
GET /api/v1/glossaries | series:read | List your shared glossaries |
POST /api/v1/glossaries | content:write | Create a shared glossary |
GET /api/v1/glossaries/{id} | series:read | One shared glossary and its terms |
GET /api/v1/glossaries/{id}/export/{format} | series:read | Download it as JSON, CSV or TBX |
POST /api/v1/glossaries/{id}/import | content:write | Import a JSON, CSV or TBX file (?dry_run=true to preview) |
GET /api/v1/series/{id}/shared-glossary | series:read | The shared glossary a series follows |
PUT /api/v1/series/{id}/shared-glossary | content:write | Attach a series to a shared glossary, or detach it |
GET /api/v1/books | narrative:read | List your books |
GET /api/v1/books/{id}/narrative-context | narrative:read | The Narrative Context Bundle of a book: its text and its memory (see Narrative context) |
GET /api/v1/books/{id}/narrative-context/revision | narrative:read | The bundle’s fingerprints alone |
GET /api/v1/series/{id}/narrative-context | narrative:read | The 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.jsondescribes/api/v1in 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.pyis 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 out429 rate_limitedanswers (it reports429 queue_fullinstead 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 8080Its
LibrisClientclass can also be imported in your own code; run it with--helpfor every option. The client sends the token only to the origin ofLIBRIS_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, orhttpstohttp— with the error coderedirect_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
- In the interface, open My account › API tokens. Tokens are personal: each account creates its own, administrators included.
- Under Create a token, give it a name, tick the permissions it needs and choose an expiration (30, 90 or 365 days, or never).
- 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).
- 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.
| Scope | Interface label | Allows |
|---|---|---|
series:read | Read series | GET /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:write | Send content | POST /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:start | Start the pipeline | Together with content:write: requests that start the translation (the default). Without it, only start=false (import only) is accepted. |
jobs:read | Follow jobs | GET /api/v1/translation-requests/{id} |
jobs:control | Control jobs (pause, resume, cancel) | POST …/pause, …/resume, …/cancel |
results:read | Read results | GET /api/v1/translation-requests/{id}/result |
narrative:read | Read 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:
| Route | Body and answer |
|---|---|
GET /api/tokens | The 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/tokens | Body {"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}/queue | Body {"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}/budget | Body {"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(normalunless set otherwise) nor the account’s ceiling (highfor administrators and for accounts an administrator allowed in Settings › Queue,normalotherwise; an administrator may also lower an account tolowthere). Above it, the request is refused with403 priority_not_allowedandmax_priorityin the error. Without a priority, a request runs atnormal, or at the lower ceiling of a token or an account limited tolow. max_runninglimits the token’s jobs running at once: the next ones wait (queue.reasonistoken_limit). The account’s own limit (QUEUE_MAX_RUNNING_PER_ACCOUNTor its row in Settings › Queue) applies as well (account_limit).max_queuedlimits the token’s requests and jobs waiting to start. A new request over it, or over the account’s waiting quota, is refused with429 queue_fullandscope(tokenoraccount) andlimitin the error; nothing is stored. Retry once one of them has started, not at once: unlikerate_limited, this answer has noRetry-After. A replay of an accepted request (sameIdempotency-Keyorexternal_id) is still answered. Resuming a paused request counts as a new entry in the queue, so…/resumemay answer429 queue_fulltoo.
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 (
starttrue, the default) is refused with402 budget_exceeded; the error carriesbudget: {amount, spent, period, resets_at}(resets_at: start of the next month,nullfor 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.…/resumeanswers409 budget_exceededuntil the cap is raised (PUT /api/tokens/{id}/budget). A request left paused longer thanAPI_REQUEST_STALL_MINUTESfails 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 with202that is refused at that point endsfailed, with the reason inerror. - 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 with402 licence_quota_insufficient— or402 allowance_insufficientfor the account’s own monthly allowance — withwordsandleft(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
failedwithstop_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, likePOST /api/projects/{id}/jobsin the browser, answers402 licence_quota_insufficientfor a whole-book translation, withwords(what the book would cost) andleft. - 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:
| Input | How to send it | Default result |
|---|---|---|
| An EPUB | multipart/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 string | The translated EPUB |
| TXT or DOCX chapters | multipart/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 headings | JSON |
| A JSON document | Content-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) orseries_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 answers409 volume_conflict. - Languages default to the language declared in the EPUB (
enwhen none) and to the series’ target language (frwhen 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, answers422 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 inreport.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.txtwith12.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 inreport.decisions.intakewith a low confidence. - Titles are taken from the file names.
- DOCX files are sent the same way (
.docxinstead 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 asChapter 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 unsupportedOne Thousandis 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 (
kindfront_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)andChapter 12 (2/2)are the two parts of chapter 12. A prologue, an interlude, a side story or an epilogue keeps itskindand 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.itemsof 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=headingstakes exactly one file (422 invalid_payloadotherwise) 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"
| Field | Required | Rules |
|---|---|---|
external_id | no | Your identifier for the request: starts with a letter or digit, then letters, digits and ._:/-, up to 200 characters. Unique per owner (see Sending twice). |
series | yes | id (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.number | yes, unless volume.latest | 1–10000. |
volume.latest | no | Default 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.title | no | The 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. |
author | no | Up to 500 characters. |
source_language, target_language | yes | BCP 47 tags such as en, fr-FR, zh-Hant, es-419. Applied to the volume. |
chapters | yes | 1 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_chapters | no | Default 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_human | no | Default 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.start | no | Default true: run the whole pipeline (needs the pipeline:start scope). false only imports the chapters. |
pipeline.provider_id | no | See Choosing a provider. |
pipeline.quality | no | fast, normal, high or maximum (see the autopilot guide). |
pipeline.context_backend | no | internal, openviking or hybrid (see OpenViking). |
pipeline.final_review | no | Default true. false skips the final review. It never runs when the server sets FINAL_REVIEW_ENABLED=false. |
pipeline.priority | no | low, normal (default) or high, within the token’s ceiling (see Queue priority and quotas). |
pipeline.analysis_mode | no | parallel or strict; default: the volume’s choice, else ANALYSIS_MODE (parallel). See Analysis modes. |
pipeline.threads | no | 1–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_id | no | The 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.format | no | Default format of the result: json, txt, txt-zip or epub-bilingual. |
callback_url | no | A webhook called when the request ends (see Webhooks). |
callback_events | no | Extra 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": "…"}
]
| Field | Meaning |
|---|---|
kind | chapter (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. |
number | For 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_count | Part 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. |
label | The name shown after the kind (Ayla in Interlude – Ayla), at most 200 characters. |
after | Insert 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. |
position | Insert 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,threadsat 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 thenwaitingwithstop_reason: earlier_volume, andqueue.reasonisearlier_volume). That wait lastsAPI_REQUEST_STALL_MINUTESat 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.
| Option | Meaning |
|---|---|
series or series_id | The series by name (created when missing) or by id; not both. Required for TXT, optional for an EPUB (standalone volume otherwise). |
volume | Volume number (1–10000), or latest for the series’ last volume (TXT only, see volume.latest above). Required for TXT. |
external_id | Your identifier of the request. |
volume_external_id | Your identifier of the volume. |
title, author | Volume title and author (an EPUB keeps its own otherwise). |
source_language, target_language | BCP 47 tags. Both required for TXT. |
provider_id, quality, context_backend, final_review, priority, analysis_mode, threads | As in pipeline above. |
start | true (default) runs the whole pipeline; false only imports. |
output_format | epub (EPUB input only; the default for an EPUB), json, txt, txt-zip or epub-bilingual. |
callback_url | See Webhooks. |
callback_events | Comma-separated extra events, for example chapters.translated (see callback_events above). |
replace_changed_chapters, discard_human | TXT and DOCX only, as in the JSON document. |
split | TXT and DOCX only: headings cuts one file at its chapter headings (details); none (default) keeps each file as one chapter. |
filename | Raw 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
afterputs them (see Irregular chapters), and matched byexternal_id, then by kind, number and part: a chapter sent again with the same text isunchanged, one with another text is refused unlessreplace_changed_chaptersis 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
scopein 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
- 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. - 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.)
- Finalizing. The job ended; Libris builds and stores the result, then writes the report.
- Ended. One of the final statuses below.
Everything lives in the database: restarting the API or the worker loses nothing.
| Status | Meaning |
|---|---|
queued | Waiting for the volume to be free. |
imported | Chapters imported, nothing started (start was false). This is an end state. |
pending, running | The job is waiting for a worker, or working. |
paused | Paused by you or by a person in the interface. |
waiting | The provider is temporarily unavailable, retried automatically; or, with stop_reason: earlier_volume, the analysis waits for an earlier volume of the series. |
blocked | Needs attention, for example the provider refuses its credentials. |
finalizing | The job ended; the result is being built. |
completed | Every passage is translated; the result is stored. |
completed_with_residuals | The 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. |
failed | See error: the job failed, the EPUB could not be repaired, the job stayed stalled too long, or the request ran past its maximum duration. |
cancelled | Cancelled 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
}
| Field | Meaning |
|---|---|
stage | Current stage of the volume: import, analysis, translation, review or export (null without a job). |
step | Current step of the job (for example translation, final_review, autopilot, arbitration). |
progress | segments, 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. |
estimate | Remaining time and cost, once enough model calls have been observed; otherwise null. |
error, stop_reason, next_attempt | Why the job stopped or is waiting, and when it will retry (Unix time, 0 when not waiting). |
priority | The request’s priority (low, normal, high), as changed by a person in the interface if it was. |
queue | While 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. |
chapters | How 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. |
result | Once stored: format, media_type, filename, size, sha256, created_at. |
report | The completion report, once the request ended. |
webhook | Only when a callback_url was given: state (pending, delivered, failed), attempts, last error. |
chapter_events | Only 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:
scope | Chapters |
|---|---|
request (default) | The chapters the request sent, unchanged ones included; for an EPUB, the whole book. |
new | Only the chapters the request created or replaced: the new chapters of a follow-up. |
volume | Every 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"}
}
sha256is the SHA-256 oftranslation(UTF-8);source_sha256is 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 itsexternal_id.display_labelis 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. issueslists unresolved quality issues (segment_id,severity,code,message);flagged_passageslists passages still flagged (check,errororrefused, not validated).strategynames the provider and model only, never the provider’s address or key.reportis the completion report once the request ended,nullbefore.
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": "…", "…": "…"}]}
}
| Field | Meaning |
|---|---|
outcome, reason | completed, completed_with_residuals, failed or cancelled, and why when it did not complete. |
passages | Counts over the request’s passages (the whole book for an EPUB). |
residuals | Passages 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. |
usage | Model calls of the request’s job and their tokens. cost only counts calls with a known price, and is null when none had one. |
cost | The 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. |
durations | Seconds since the request was created, spent waiting for the volume, and spent in the job. |
autopilot | How the autopilot ended (null when it did not run). |
decisions | autopilot: 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_map | Each 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). |
quality | Quality 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. |
delivery | EPUB 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:
| Setting | Environment variable | Default |
|---|---|---|
Allowed hosts (hooks.example.org, *.partner.example for its subdomains) | API_WEBHOOK_HOSTS (comma-separated) | empty: webhooks refused |
| Allowed private networks, in CIDR notation | API_WEBHOOK_PRIVATE_NETWORKS | empty |
| Attempts at most | API_WEBHOOK_MAX_ATTEMPTS | 6 |
| Timeout of a call, in seconds | API_WEBHOOK_TIMEOUT_SECONDS | 10 |
| Global signing secret (32 characters at least) | API_WEBHOOK_SECRET | empty |
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
}
| Header | Value |
|---|---|
X-Libris-Event | translation_request.finished |
X-Libris-Delivery | <request id>:<attempt number> |
X-Libris-Timestamp | Unix time in seconds |
X-Libris-Signature | sha256=<hex>: HMAC-SHA256 of <timestamp>.<body> |
User-Agent | Libris-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
httporhttpsURL 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:
| Field | Values | Default |
|---|---|---|
file | The glossary file (2 MB at most) | required |
strategy | skip keeps terms in place; replace replaces unlocked terms that differ; replace_all replaces locked ones too | skip |
delimiter | semicolon, comma or tab | detected |
mapping | JSON object from field to column number (from 0), for example {"source": 0, "translation": 2} | from the headers |
header | true or false: whether the first row holds column names | detected |
skip_invalid | true leaves invalid rows out instead of refusing the file | false |
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 path | Body and result |
|---|---|
GET /api/users | List 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-factor | Removes 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 route | Contract |
|---|---|
GET /api/auth/ldap | Public. {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/ldap | Administrator. 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/test | Administrator. {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}/directory | Administrator, 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 route | Contract |
|---|---|
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/begin | Returns {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/begin | Returns {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 path | Body 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}/test | PROPFIND (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-publications | The 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.
| HTTP | code | When |
|---|---|---|
| 401 | missing_token, invalid_token, revoked_token, expired_token, inactive_account | No token, or a bad one (header WWW-Authenticate: Bearer). |
| 401 | unauthorized | A body above 1 MiB sent without an Authorization: Bearer header, refused before it is read. A smaller request without a token gets missing_token. |
| 402 | automation_not_licensed | The 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. |
| 402 | budget_exceeded (with budget) | The token’s cost budget is reached: a request that would start work is refused (see Token budget). |
| 403 | insufficient_scope (with scope) | The token lacks a permission. |
| 403 | forbidden | A browser request from another site (see below). |
| 403 | priority_not_allowed (with max_priority) | The priority asked for is above the token’s or the account’s ceiling. |
| 404 | request_not_found, series_not_found, volume_not_found, glossary_not_found, not_found | Unknown, or owned by someone else. |
| 409 | idempotency_conflict (with request_id) | Same key or external_id, different content. |
| 409 | chapter_conflict (with conflicts) | Chapters exist with another text; send replace_changed_chapters. |
| 409 | conflict (with protected_segments) | A replacement would drop human edits; send discard_human. |
| 409 | volume_conflict, series_archived, volume_archived | The target volume cannot take this content. |
| 409 | result_not_ready, request_failed, request_cancelled, format_unavailable | The result cannot be served (see Get the result). |
| 409 | not_started, conflict | Pause, resume or cancel not allowed in the current state (see Pause, resume or cancel). |
| 409 | glossary_exists, language_mismatch | A shared glossary of that name exists; its languages differ from the series’. |
| 409 | budget_exceeded | Resuming a job paused by a book or token budget that is still reached. |
| 413 | payload_too_large, glossary_too_large | The body is above the size limit. |
| 415 | unsupported_media_type | Neither JSON, EPUB nor multipart. |
| 422 | invalid_payload (with errors: [{loc, msg, type}]) | The document or the upload is invalid. |
| 422 | invalid_request (with errors) | A bad query parameter (for example format, wait). |
| 422 | invalid_idempotency_key, unknown_provider, provider_required, invalid_epub, fixed_layout_epub, callback_refused, delivery_failed | See the sections above. |
| 422 | invalid_placement | A chapter’s after names no chapter of the volume or of the request. |
| 422 | invalid_glossary, invalid_strategy, invalid_mapping, invalid_name | The glossary file, its import options or the glossary name (see Shared glossaries). |
| 429 | rate_limited | Too many calls for this token (header Retry-After). |
| 429 | queue_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. |
| 500 | server_error | Unexpected 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
| Setting | Default | Effect |
|---|---|---|
API_MAX_PAYLOAD_MB | MAX_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_CHAPTERS | 2000 | Chapters (or TXT files) per request. |
TEXT_CHAPTER_MAX_CHARS | 2,000,000 | Characters per chapter, shared with TXT imports. |
API_RATE_LIMIT_PER_MINUTE | 120 | Calls 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_SECONDS | 60 | Longest ?wait= (0–600). |
API_REQUEST_STALL_MINUTES | 360 | A 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_HOURS | 168 | A request still unfinished after this fails, and the job is cancelled. |
DELIVERY_REPAIR_ATTEMPTS | 3 | EPUBCheck repair rounds of a delivered EPUB. |
RETENTION_RESULTS_DAYS | 30 | Days a stored result file is kept (it can be rendered again afterwards). |
API_WEBHOOK_* | see Webhooks | Webhook hosts, networks, secret, attempts and timeout. |
QUEUE_* | see configuration | Jobs 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}'
Public proofreading links
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:
| Endpoint | Bounded page | Continuation |
|---|---|---|
GET /api/review/book?offset=0 | 100 chapter headings, plus title, author, target language and the link’s label/source visibility | next_offset, or null at the end |
GET /api/review/chapters/{chapter_id}?offset=0 | 2,000 translated passages, as an export gives them; original text only when explicitly allowed by the link | next_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 (404otherwise). Answersapplication/zip:series.jsonand one project archive per volume (volumes/<n>.zip). Refused with413when the import could not read it back (MAX_UPLOAD_MB,MAX_ENTRIES, or a volume over its own limits, named in the message), and409when a volume’s source files are missing on the server.POST /api/series/import— multipart fieldfile. Creates a new series of the caller and answers201withid,name,renamed_from(the archive’s name when it was already taken and the series becameName (2)…, otherwisenull) andvolumes(the new project ids, in the archive’s order). Everything is checked before anything is written:422names the incorrect field (links.0.entity_id,entities.3.merged_into_id…) or the volume and its problem;409when a volume’s EPUB is already in the caller’s library;413aboveMAX_UPLOAD_MB. A volume archive sent here, or a series archive sent toPOST /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.
| Route | Purpose |
|---|---|
GET /api/settings/mail | Effective settings, saved, configured, has_password; never a password |
PUT /api/settings/mail | Complete override: enabled, host, port, starttls, username, sender, timeout, optional write-only password |
DELETE /api/settings/mail | Restore the installation’s environment values |
POST /api/settings/mail/test | { "recipient": "reader@example.com" }; 202 with queued and message id |
GET /api/settings/mail/outbox | Optional `status=pending |
POST /api/settings/mail/outbox/{id}/retry | Requeue 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 inapp_settings(keyexports), no schema change. - Book (writer of the volume):
PATCH /api/projects/{pid}/editionwith{"ai_disclosure": true | false | null};nullgoes back to the installation’s choice. GETandPATCH /api/projects/{pid}/editionanswerai_disclosure(the value the next export uses: the book’s choice, else the installation’s),ai_disclosure_default(the installation’s) andai_disclosure_book(the book’s own choice,null= follows the installation). An interface showsai_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 path | Body 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.