API 레퍼런스
REST로 2Novel을 자동화하세요. 책 만들기, 화 추가, 번역, 결과 가져오기를 브라우저 없이 할 수 있어요. 모든 요청은 API 키로 인증해요.
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 as a language tag such as 'zh', 'ja', 'ko' or 'en' (defaults to 'auto', detected per chapter). There is no fixed list of pairs: any source and target can be set; the engine carries extra rules for Chinese, Japanese, Korean and Vietnamese. |
| targetLang | string | Target language as a language tag, e.g. 'vi' or 'en' (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": "凡人修仙传",
"sourceLang": "zh",
"targetLang": "en",
"autoApprove": true
}'201 Created
{
"book": {
"id": "b1a2c3d4-...",
"title": "凡人修仙传",
"sourceLang": "zh",
"targetLang": "en",
"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": "第一章 上山",
"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. |
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": "凡人修仙传", ... },
"chapters": [
{
"id": "c9f8e7d6-...",
"orderNo": 1,
"title": "第一章 上山",
"translatedTitle": "Chapter 1: Up the Mountain",
"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": "Chapter 1: Up the Mountain",
"sourceText": "韩立坐在这座普普通通的茅屋之中...",
"translatedText": "Han Li sat inside this perfectly ordinary thatched hut...",
"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": "凡人修仙传",
"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.
Rate limits and quotas
An API key has no rate limit in requests per minute (RPM) and no Retry-After header to watch. Translation is a queue: POST /translation/books/{id}/translate accepts up to 1000 chapter ids per call (none means every chapter) and returns at once; chapters are translated in the background and you follow them with GET /books/{id} or a webhook.
| What stops a run | HTTP | What happens |
|---|---|---|
| Workspace balance cannot cover the chapters | 402 | out_of_funds; top up, then translate again. |
| A token budget you set (workspace or project, per period) is used up | 429 | budget_exceeded with details: { scope, period, used, limit }; checked before the run and before each model call. |
| The model provider throttles (its own 429) | — | The chapter stays queued and is retried after the provider’s wait; your request does not fail. |
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. |
| 429 | budget_exceeded | A token budget set for the workspace or project is used up for this period. |
| 500 | internal_error | Server error — try again later. |