API Reference
Automate 2Novel over REST: create books, add chapters, translate, and fetch results — no browser needed. Every request authenticates with an API key.
Authentication
Every endpoint requires an API key. Create one in Settings → API Keys — the key is shown only once after creation, so save it immediately. Send it in the Authorization header on every request:
https://2novel.app/apiAuthorization: Bearer ltr_xxxxxxxxxxxx$KEY:export KEY="ltr_xxxxxxxxxxxx"
export BASE="https://2novel.app/api"Workflow
A complete translation lifecycle has 5 steps. Step 3 only queues the work (it returns 202 immediately); use step 5 to wait until a chapter turns done, then read the translation in step 4.
- 1
Create a book
POST /books — get back bookId
- 2
Add a chapter
POST /books/{id}/chapters — get back chapterId
- 3
Translate chapters
POST /translation/books/{id}/translate — queued
- 4
Get the translation
GET /books/{id}/chapters/{chapterId} — read translatedText
- 5
Translation status
GET /books/{id} — watch chapters[].status
/books1 · Create a book
book.id — every later step needs it.| Field | Type | Description |
|---|---|---|
| title* | string | Book title (1–300 characters). |
| sourceLang | string | Source language (defaults to 'auto'). |
| targetLang | string | Target language (defaults to 'vi'). |
| autoApprove | boolean | Automatically analyze (build the Book Bible) and apply it before translating each unanalyzed chapter. Defaults to true — leaving it out turns it on. Send false to translate raw, without analysis. |
curl -X POST "$BASE/books" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Phàm Nhân Tu Tiên",
"sourceLang": "zh",
"targetLang": "vi",
"autoApprove": true
}'201 Created
{
"book": {
"id": "b1a2c3d4-...",
"title": "Phàm Nhân Tu Tiên",
"sourceLang": "zh",
"targetLang": "vi",
"autoApprove": true,
"createdAt": "2026-06-30T08:00:00.000Z"
}
}/books/{id}/chapters2 · Add a chapter
multipart/form-data to upload several files at once (PDF/DOCX/TXT) — send the files and startOrderNo fields; each file becomes one chapter, numbered in filename order.| Field | Type | Description |
|---|---|---|
| orderNo* | number | Chapter order number (positive integer, unique within the book). |
| title | string | Chapter title (up to 300 characters, may be empty). |
| sourceText* | string | Source text to translate (1–500,000 characters). |
curl -X POST "$BASE/books/$BOOK_ID/chapters" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"orderNo": 1,
"title": "Chương 1: Lên núi",
"sourceText": "韩立坐在这座普普通通的茅屋之中..."
}'201 Created
{
"chapters": [
{
"id": "c9f8e7d6-...",
"bookId": "b1a2c3d4-...",
"orderNo": 1,
"title": "第一章 上山",
"sourceText": "韩立坐在...",
"translatedText": null,
"translatedTitle": null,
"status": "idle",
"errorMsg": null,
"prepassAt": null
}
]
}/translation/books/{id}/translate3 · Translate chapters
queued is how many chapters just entered the queue. Track progress in step 5, fetch results in step 4.| Field | Type | Description |
|---|---|---|
| chapterIds | string[] | Chapters to translate (up to 1000). Omit to translate every chapter in the book. |
| model | string | Model name (1–64 characters). Omit to use the workspace default model. |
| useBible | boolean | Enable the Book Bible (consistent terms/characters). Defaults to true. |
| passphrase | string | Passphrase that unlocks a BYOK key (only Pro workspaces use their own keys). Leave empty when using the system key. |
curl -X POST "$BASE/translation/books/$BOOK_ID/translate" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"chapterIds": ["c9f8e7d6-..."],
"useBible": true
}'202 Accepted
{
"queued": 1
}/books/{id}4 · List chapters
sourceText/translatedText, so a thousand-chapter book stays a small response you can poll continuously. hasTranslation tells you a chapter has its translation; the translated title lives in translatedTitle (falling back to the original title while empty). Fetch each chapter’s full content from the endpoint right below.curl "$BASE/books/$BOOK_ID" \
-H "Authorization: Bearer $KEY"200 OK
{
"book": { "id": "b1a2c3d4-...", "title": "Phàm Nhân Tu Tiên", ... },
"chapters": [
{
"id": "c9f8e7d6-...",
"orderNo": 1,
"title": "第一章 上山",
"translatedTitle": "Chương 1: Lên núi",
"status": "done",
"hasTranslation": true,
"sourceCharCount": 2431,
"errorMsg": null,
"prepassAt": "2026-06-30T08:05:00.000Z"
}
]
}/books/{id}/chapters/{chapterId}4b · Get one chapter’s content
sourceText and translatedText — the endpoint to call when you actually want to read or download a translation. translatedText is null until the chapter finishes (status: "done"). Edit a translation with PATCH on the same path (fields title, translatedText).curl "$BASE/books/$BOOK_ID/chapters/$CHAPTER_ID" \
-H "Authorization: Bearer $KEY"200 OK
{
"chapter": {
"id": "c9f8e7d6-...",
"bookId": "b1a2c3d4-...",
"orderNo": 1,
"title": "第一章 上山",
"translatedTitle": "Chương 1: Lên núi",
"sourceText": "韩立坐在这座普普通通的茅屋之中...",
"translatedText": "Hàn Lập ngồi trong căn nhà tranh bình thường này...",
"status": "done",
"errorMsg": null,
"prepassAt": "2026-06-30T08:05:00.000Z"
}
}/books/{id}5 · Check translation status
status field: idle → queued → running → done (or failed, with errorMsg). Poll every few seconds until every chapter is done. The /jobs endpoint shows the live queue detail.# Option 1 — per-chapter status (same endpoint as step 4)
curl "$BASE/books/$BOOK_ID" \
-H "Authorization: Bearer $KEY" | jq '.chapters[] | {orderNo, status}'
# Option 2 — the book's live job detail
curl "$BASE/translation/books/$BOOK_ID/jobs" \
-H "Authorization: Bearer $KEY"200 OK (GET /translation/books/{id}/jobs)
{
"jobs": [
{
"id": "j1...",
"bookId": "b1a2c3d4-...",
"chapterId": "c9f8e7d6-...",
"model": "gpt-4o",
"status": "running",
"attempts": 1,
"error": null
}
]
}Book Bible
The Book Bible is the per-book consistency layer — the glossary of terms, characters, relationships and idioms the translator applies to every chapter. The endpoints below read and edit it; every path sits under /books/{id} and uses the same API key.
| Endpoint | Purpose |
|---|---|
| GET /books/{id}/glossary | The book’s term list. |
| POST /books/{id}/glossary | Add/edit a term (sourceTerm, targetTerm, category, note, locked). |
| DELETE /books/{id}/glossary/{termId} | Delete a term. |
| GET /books/{id}/characters | The character list. |
| POST /books/{id}/characters | Add a character (nameVi, nameCn, aliases, role, faction, realm, …). |
| GET /books/{id}/characters/{slug} | One character’s detail by slug. |
| PATCH /books/{id}/characters/{slug} | Edit a character — name, relationships, abilities, equipment, timeline. |
| DELETE /books/{id}/characters/{slug} | Delete a character. |
| GET /books/{id}/relations | Relationships between characters — filter by fromSlug/toSlug/fromOrder/toOrder, paginate with limit/offset. |
| GET /books/{id}/character-events | A character’s per-chapter events — filter by characterSlug, fromOrder/toOrder. |
| GET /books/{id}/idioms | The book’s idioms / proverbs / allusions. |
| POST /books/{id}/idioms | Add an idiom (sourceText, hanViet, viEquivalent, …). |
| DELETE /books/{id}/idioms/{idiomId} | Delete an idiom. |
| GET /books/{id}/conflicts | Suspected-duplicate characters awaiting review — filter by status. |
| POST /books/{id}/conflicts/{conflictId}/resolve | Decide merge / keep / dismiss on a conflict. |
| GET /books/{id}/lessons | Lessons learned from translation edits. |
| DELETE /books/{id}/lessons/{lessonId} | Dismiss a lesson. |
| GET /books/{id}/export | Export the whole Book Bible (optional novelId parameter). |
curl "$BASE/books/$BOOK_ID/export" \
-H "Authorization: Bearer $KEY"Webhook
Instead of polling /books/:id/chapters to learn whether a translation has finished, register an endpoint and let 2Novel call you. Create it in Settings → Webhook (Pro plan); each endpoint gets its own signing secret, shown only once at creation.
A workspace can register several endpoints — typically one for production and one for staging, or to migrate to a new receiver without missing an event.
Events
| event | When |
|---|---|
| book.chapters_translated | A book just had chapters finish translating. Coalesced: a 1,000-chapter batch produces ONE call, about 30 seconds after the first chapter finished. |
| ping | You pressed “Send test”. It travels the real delivery path, so a green test proves the real path works. |
What gets sent
The body is a doorbell, not a parcel — it carries no chapter content. Once you receive it, call the API above to fetch the translations. Note: sending content along would mean one request per chapter, and a large batch would become thousands.
X-2Novel-Signature: 9f2b… (hex, HMAC-SHA256)
X-2Novel-Timestamp: 1787760000 (unix seconds)
Content-Type: application/json
{
"schema_version": 1,
"event": "book.chapters_translated",
"book_id": "8f14e45f-ceea-467a-9fd9-9f2b0e4f7f10",
"title": "Phàm Nhân Tu Tiên",
"pending_since": "2026-08-27T03:12:44.512Z"
}Verifying the signature
The signature is the HMAC-SHA256 of the string <timestamp>.<raw body> with the endpoint’s signing secret. Compare over the raw body — parsing then re-serializing changes every byte and the signature will never match. Reject requests whose timestamp is off by more than 5 minutes to block replays.
import crypto from 'node:crypto'
app.post('/2novel/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8') // raw, not yet parsed
const ts = req.get('X-2Novel-Timestamp')
const sig = req.get('X-2Novel-Signature')
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401)
const expected = crypto
.createHmac('sha256', process.env.LITRANS_WEBHOOK_SECRET)
.update(`${ts}.${raw}`)
.digest('hex')
const a = Buffer.from(sig, 'hex')
const b = Buffer.from(expected, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401)
const { event, book_id } = JSON.parse(raw)
if (event === 'book.chapters_translated') void pullChapters(book_id)
res.sendStatus(200) // ack fast, work later
})What you return
| HTTP | 2Novel reads it as |
|---|---|
| 2xx | Received. The window closes, no retry. |
| 409 | You are busy with this very book. It comes back after 60 seconds and does NOT count as a failure — the right answer while you are still processing the previous batch. |
| 4xx / 5xx / timeout | Failure. Retried at 60s → 120s → 300s → 600s → 900s, then dropped. After 5 consecutive exhausted attempts the endpoint is disabled and you must re-enable it by hand. |
The endpoint must be https:// on a public server. Addresses pointing into a private network are rejected both at registration and at send time — checked twice because a public hostname can still be redirected afterwards.
Receiving the same event twice is normal (a retry after you already processed it). Make the receiver idempotent; use pending_since to tell a new window apart from a resend of an old one.
Error codes
Errors come back as JSON { "error": "<code>", "message"?: "..." } with the matching HTTP status.
| HTTP | error | Meaning |
|---|---|---|
| 400 | invalid_body / invalid_json_body | Malformed body or a required field is missing. |
| 401 | Unauthorized | Missing or wrong API key (Authorization header). |
| 402 | out_of_funds | The workspace balance cannot cover the translation. |
| 404 | book_not_found / chapter_not_found | Resource not found (or outside your workspace). |
| 409 | chapter_order_exists | orderNo already exists in the book. |
| 500 | internal_error | Server error — try again later. |