API

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:

Base URL
https://2novel.app/api
Auth header
Authorization: Bearer ltr_xxxxxxxxxxxx
Books, chapters and API keys all belong to your workspace. Each translation is billed against the balance by source character count — make sure the workspace is funded before calling. The examples below use the shell variable $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. 1

    Create a book

    POST /books — get back bookId

  2. 2

    Add a chapter

    POST /books/{id}/chapters — get back chapterId

  3. 3

    Translate chapters

    POST /translation/books/{id}/translate — queued

  4. 4

    Get the translation

    GET /books/{id}/chapters/{chapterId} — read translatedText

  5. 5

    Translation status

    GET /books/{id} — watch chapters[].status

POST/books

1 · Create a book

Creates a new book in your workspace. Keep the book.id — every later step needs it.
FieldTypeDescription
title*stringBook title (1–300 characters).
sourceLangstringSource language (defaults to 'auto').
targetLangstringTarget language (defaults to 'vi').
autoApprovebooleanAutomatically 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.
Request
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
  }'
Response
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"
  }
}
POST/books/{id}/chapters

2 · Add a chapter

Adds a chapter by pasting text (JSON as above). The endpoint also accepts 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.
FieldTypeDescription
orderNo*numberChapter order number (positive integer, unique within the book).
titlestringChapter title (up to 300 characters, may be empty).
sourceText*stringSource text to translate (1–500,000 characters).
Request
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": "韩立坐在这座普普通通的茅屋之中..."
  }'
Response
201 Created
{
  "chapters": [
    {
      "id": "c9f8e7d6-...",
      "bookId": "b1a2c3d4-...",
      "orderNo": 1,
      "title": "第一章 上山",
      "sourceText": "韩立坐在...",
      "translatedText": null,
      "translatedTitle": null,
      "status": "idle",
      "errorMsg": null,
      "prepassAt": null
    }
  ]
}
POST/translation/books/{id}/translate

3 · Translate chapters

Queues the selected chapters for translation and returns immediately (asynchronous). queued is how many chapters just entered the queue. Track progress in step 5, fetch results in step 4.
FieldTypeDescription
chapterIdsstring[]Chapters to translate (up to 1000). Omit to translate every chapter in the book.
modelstringModel name (1–64 characters). Omit to use the workspace default model.
useBiblebooleanEnable the Book Bible (consistent terms/characters). Defaults to true.
passphrasestringPassphrase that unlocks a BYOK key (only Pro workspaces use their own keys). Leave empty when using the system key.
Request
curl -X POST "$BASE/translation/books/$BOOK_ID/translate" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "chapterIds": ["c9f8e7d6-..."],
    "useBible": true
  }'
Response
202 Accepted
{
  "queued": 1
}
GET/books/{id}

4 · List chapters

Returns the book with a light chapter index — no 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.
Request
curl "$BASE/books/$BOOK_ID" \
  -H "Authorization: Bearer $KEY"
Response
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"
    }
  ]
}
GET/books/{id}/chapters/{chapterId}

4b · Get one chapter’s content

Returns one chapter with its full 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).
Request
curl "$BASE/books/$BOOK_ID/chapters/$CHAPTER_ID" \
  -H "Authorization: Bearer $KEY"
Response
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"
  }
}
GET/books/{id}

5 · Check translation status

Each chapter carries a 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.
Request
# 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"
Response
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.

EndpointPurpose
GET /books/{id}/glossaryThe book’s term list.
POST /books/{id}/glossaryAdd/edit a term (sourceTerm, targetTerm, category, note, locked).
DELETE /books/{id}/glossary/{termId}Delete a term.
GET /books/{id}/charactersThe character list.
POST /books/{id}/charactersAdd 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}/relationsRelationships between characters — filter by fromSlug/toSlug/fromOrder/toOrder, paginate with limit/offset.
GET /books/{id}/character-eventsA character’s per-chapter events — filter by characterSlug, fromOrder/toOrder.
GET /books/{id}/idiomsThe book’s idioms / proverbs / allusions.
POST /books/{id}/idiomsAdd an idiom (sourceText, hanViet, viEquivalent, …).
DELETE /books/{id}/idioms/{idiomId}Delete an idiom.
GET /books/{id}/conflictsSuspected-duplicate characters awaiting review — filter by status.
POST /books/{id}/conflicts/{conflictId}/resolveDecide merge / keep / dismiss on a conflict.
GET /books/{id}/lessonsLessons learned from translation edits.
DELETE /books/{id}/lessons/{lessonId}Dismiss a lesson.
GET /books/{id}/exportExport the whole Book Bible (optional novelId parameter).
Example — export the Book Bible
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

eventWhen
book.chapters_translatedA book just had chapters finish translating. Coalesced: a 1,000-chapter batch produces ONE call, about 30 seconds after the first chapter finished.
pingYou 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.

POST https://api.your-server.com/2novel/webhook
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.

Node.js
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

HTTP2Novel reads it as
2xxReceived. The window closes, no retry.
409You 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 / timeoutFailure. 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.

HTTPerrorMeaning
400invalid_body / invalid_json_bodyMalformed body or a required field is missing.
401UnauthorizedMissing or wrong API key (Authorization header).
402out_of_fundsThe workspace balance cannot cover the translation.
404book_not_found / chapter_not_foundResource not found (or outside your workspace).
409chapter_order_existsorderNo already exists in the book.
500internal_errorServer error — try again later.