Novelmint Publish API — v1 Reference
Current version: v1. Last updated 2026-08-13.
Everything a business needs to send a finished catalogue to Novelmint and have it earn from readers. If you are still deciding whether to integrate, the programme rules — who owns the books, how consent works, how the revenue share is paid — are at /partners/docs.
This page is also available as plain markdown.
1. Versioning
The version is in the path.
https://novelmint.ai/api/publish/v1/...
Every response carries Novelmint-Publish-Version: 1.
What is allowed to change inside v1 — new endpoints, new optional request fields, new
response fields, new reason_code values, new error codes. Your integration must ignore
fields and codes it does not recognise; that requirement is the whole reason v1 can improve
without breaking you.
What forces v2 — removing or renaming a field, changing a field's type, changing the meaning of an existing value, or making an optional request field required. A v2 is a new path. v1 keeps serving, and any retirement date is announced in writing with notice, never implied by a version bump.
Unversioned paths (/api/publish/books, without v1) exist from before versioning and
forward to v1 permanently. They are safe to keep using, and a future v2 does not change what
they serve — an unversioned caller is a v1 caller for good. New integrations should use the
explicit path.
2. Authentication
A bearer token, minted in the partner dashboard under Tokens.
Authorization: Bearer pub_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The secret is shown once, at creation. We store a hash; nobody at Novelmint can read your token back to you. Lose it and you mint a new one and revoke the old.
The token belongs to the business, not to the person who created it, so it survives staff changes.
Environments
| Prefix | Behaviour |
|---|---|
pub_test_ | Runs the entire pipeline and stops before publishing. Nothing reaches readers. POST .../publish returns status: "draft" with reason_code: "test_environment". |
pub_live_ | Publishes for real. |
Build against a test token first. It exercises every code path you will hit in production except the last one.
Scopes
| Scope | Grants | How you get it |
|---|---|---|
publish:create | Create books, add chapters, create grants, publish | Always |
publish:read | Read submission status | Always |
publish:update | PATCH a chapter already sent | Tick the box when you create the token |
publish:delete | — | Not issued to anyone |
publish:update is off by default and on in one click — a checkbox on the token form, no
request and no waiting. Off by default so a token built for one-way ingestion cannot rewrite
anything, which matters when the credential is sitting in someone's CI.
Scopes are fixed at creation. To change them, create a new token and revoke the old one; that is one action either way and it means a leaked token's powers never quietly grow.
publish:update only reaches books you still own. PATCH resolves ownership through the
series, so the moment a writer accepts an on_behalf title it belongs to them and the endpoint
stops applying to it. In practice the scope covers your own imprint and titles still held
pending consent — never a writer's published book.
publish:delete is not issued and is not coming soon. As designed it would hand a partner a
kill switch over a writer's catalogue after the relationship ended. Withdrawal requests go to a
human at partners@novelmint.ai, deliberately.
Authentication failures
All are 401 except insufficient_scope (403) and https_required (400). The codes are
deliberately distinguishable so a lapsed partnership does not look like a bad token.
| Code | Meaning |
|---|---|
unauthorized | Missing or malformed Authorization header |
invalid_token | No such token, or the secret does not match |
token_revoked | You revoked it |
token_expired | Past its expiry |
client_revoked | The vendor application bound to this token was revoked |
not_partner | Your Platinum partnership lapsed. Every token stops at once |
insufficient_scope | Valid token, wrong scope |
3. Conventions
Base URL https://novelmint.ai
Content type application/json on every request with a body.
Errors always take this shape, whatever went wrong:
{ "error": { "code": "title_required", "message": "A title is required." } }
Branch on code. message is written for a human reading a log and may be reworded at any
time.
Idempotency, and what replayed means
Every write is safe to repeat. When a request turns out to be a duplicate, we return the original result rather than creating a second copy, and the response says so:
{ "book_id": "3fa85f64-…", "replayed": true }
"replayed": true means nothing new was created — this already existed and here it is
again. false means this call is what created it. Two mechanisms decide it:
| Endpoint | Matched on |
|---|---|
POST /books | Your Idempotency-Key header, per partner. Same key, same book |
POST .../chapters | The content itself: same title and byte-identical prose |
The practical consequence: a failed upload can simply be re-run. If your script dies after
30 of 41 chapters, run the whole book again — the 30 come back replayed: true, the missing 11
are created, and the book ends with 41 chapters rather than 71. You do not need to track how
far you got.
Idempotency-Key is required on POST /books for this reason. Use something stable from
your own system — your catalogue id works well — not a fresh UUID per attempt, which would
defeat the point.
external_ref — your own catalogue id, up to 200 characters. Set it on create and it is
echoed on every response about that book. Without it, reconciling a few hundred submissions
against your system is a manual job.
Rate limits — keyed on the token, so a leaked credential can be cut off without stopping your other integrations.
| Group | Limit |
|---|---|
Ingestion (POST /books, .../chapters, /grants) | 2,000/hour |
Go live (POST .../publish) | 500/day |
Reads (GET /books/:id) | 600/minute |
Over the limit returns 429 with Retry-After. A 200-title backlist is roughly 8,000
requests and lands inside a working day without touching a ceiling.
4. The two modes
Chosen per book at creation and fixed thereafter.
own_imprint — you own the work. It publishes under your own account and persona,
immediately. No consent machinery.
On these titles you are the author, so you earn the author's 70% of reader spend rather than the partner's 10%. The two are not combined: the referral share exists to pay whoever brought someone else's book, and it is not paid to you on your own.
on_behalf — a writer owns the work and you have an agreement with them. The book is
ingested and held: fully uploaded, invisible to everyone, published only once that writer
consents on our own page. When they accept, ownership transfers to them, the book publishes on
the terms you supplied, and you earn 10% of reader spend on it out of Novelmint's 30% share —
never out of the writer's 70%.
You do not choose whether a title is held. The grant state decides.
5. The normal sequence
POST /api/publish/v1/books -> book_id, status "draft"
POST /api/publish/v1/books/{book_id}/chapters -> repeat, one per chapter
POST /api/publish/v1/books/{book_id}/publish -> status "live" or "held_pending_author"
GET /api/publish/v1/books/{book_id} reconcile, any time
6. Endpoints
6.1 Create a book
POST /api/publish/v1/books
Authorization: Bearer pub_live_…
Idempotency-Key: your-unique-key
Content-Type: application/json
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | ✅ | 1–300 characters |
mode | "own_imprint" | "on_behalf" | — | Defaults to own_imprint |
blurb | string | — | Back-cover copy |
language | string | — | ISO code, defaults en |
external_ref | string | — | Your catalogue id, ≤200 chars |
persona_id | uuid | — | own_imprint only: publish under a persona you already own |
author.user_id | uuid | on_behalf¹ | The writer's Novelmint account, if you know it |
author.email | string | on_behalf¹ | The writer's email, if they have no account |
author.pen_name | string | ² | Name the book is attributed to before they accept. Need not be unique — see below |
¹ on_behalf needs one of author.user_id or author.email.
² Required when identifying the writer by email.
201 Created when this call created it, 200 when it was already there (see replayed, §3)
{
"submission_id": "8f14e45f-ceea-467a-9f4b-9a0b1c2d3e4f",
"book_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"external_ref": "MER-2291",
"status": "draft",
"replayed": false,
"links": {
"chapters": "/api/publish/v1/books/3fa85f64-…/chapters",
"publish": "/api/publish/v1/books/3fa85f64-…/publish",
"self": "/api/publish/v1/books/3fa85f64-…"
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | idempotency_key_required | Header missing |
| 400 | invalid_json | Body did not parse |
| 400 | title_required / title_too_long | |
| 400 | invalid_mode | Not one of the two modes |
| 400 | author_required | on_behalf with no author identifier |
| 400 | pen_name_required | Identified by email, no pen name |
| 403 | author_blocked | This writer has asked you not to send them anything. Do not retry |
| 409 | idempotency_conflict | A different request already used this key |
| 422 | not_platinum_partner, cannot_grant_self | Grant could not be established |
Pen names do not have to be unique. Two authors can publish here under the same name, the way two people can share a display name anywhere else. Only the web address has to be distinct, so a writer taking an existing name gets a number added to theirs. Nothing is blocked and you do not need to check first — the writer is told before accepting, and can change both the name and the address themselves afterwards.
What happens to the writer. Submitting
on_behalfwith anauthor.emailsends that writer one email: what you sent, that nothing is published, and a link to accept or decline. One email per writer, not per title — a second book for the same person adds to the existing request rather than mailing them again. If you would rather bring them over from your own site instead, use the handoff flow (§6.5) and no email is sent at all.
6.2 Add a chapter
One chapter per request. There is no bulk endpoint: a 200k-word novel is over a megabyte of JSON, and a failed chapter should retry alone.
Order of arrival does not matter, provided you say where each chapter goes. Send
chapter_number and the chapter lands at exactly that position, so a job queue can post
concurrently and out of sequence. Omit it and chapters are appended in the order they arrive,
which is only safe if you are posting them one after another.
POST /api/publish/v1/books/{book_id}/chapters
| Field | Type | Required | Notes |
|---|---|---|---|
content | string | ✅ | The prose. 1 MB maximum, measured in UTF-8 bytes |
title | string | — | Defaults to Chapter {n} |
role | "main" | "prologue" | "epilogue" | "front_matter" | — | Defaults main |
chapter_number | integer | — | Where it goes. main chapters only; honoured exactly. Omit to append. Positions must be unique — a clash is 409 chapter_number_taken |
201 Created when this call created it, 200 when it was already there (see replayed, §3)
{ "chapter_id": "…", "chapter_number": 12, "role": "main", "replayed": false }
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | content_required / chapter_empty | |
| 400 | invalid_role | |
| 404 | submission_not_found | Book not found, or not yours |
| 409 | chapter_number_taken | A book holds one prologue |
| 413 | chapter_too_large | Over 1 MB |
A book you do not own returns 404, not 403, so partners cannot probe each other's ids.
6.3 Correct a chapter
Requires publish:update, which you enable with a checkbox when creating the token (§2).
PATCH /api/publish/v1/books/{book_id}/chapters/{chapter_number}
Send content, title, or both. Addressed by chapter number — what your catalogue knows
— and it never moves the chapter: fixing a typo in chapter 12 must not reshuffle the book.
200 OK
{ "chapter_id": "…", "chapter_number": 12, "content_changed": true }
6.4 Publish
POST /api/publish/v1/books/{book_id}/publish
| Field | Type | Notes |
|---|---|---|
genre | string | Defaults uncategorized |
cover_url | string | https only. We fetch and host it. Required unless a cover is already attached |
blurb | string | Omit to leave the saved blurb alone |
credits_per_chapter | integer | 1–10, defaults 5 |
200 OK — live
{
"submission_id": "…", "book_id": "…", "external_ref": "MER-2291",
"status": "live",
"book_url": "https://novelmint.ai/read/jo-ashworth/the-salt-road"
}
202 Accepted — held (on_behalf, writer has not consented yet)
{
"status": "held_pending_author",
"reason_code": "awaiting_author_consent",
"message": "Ingested and held. This title publishes when the writer accepts."
}
Nothing further is required from you. When the writer accepts, the book publishes on the terms in this call — genre, blurb, price and cover are remembered.
Idempotent: publishing an already-live title returns its current state with
"replayed": true.
422 — rejected. reason_code is the gate's own code:
reason_code | Meaning |
|---|---|
manuscript_only | Adult-rated. Cannot be sold here, whatever your contracts say |
fan_fiction_manuscript_only | The characters are not yours to license |
derivative_unresolved | Content review flagged possible third-party characters; the writer must resolve it |
no_cover | No cover attached and none supplied |
no_prose | No chapter has any content |
cover_url_not_https | Cover URL must be https |
cover_url_blocked | Resolves to an address we will not fetch |
cover_too_large | Over 10 MB |
cover_not_an_image | Not a JPEG, PNG or WebP — decided by content, not by the header you sent |
cover_unreachable | We could not fetch it |
The first three are permanent for that title. Retrying will not change the answer; they are policy, not transient failures.
6.5 Start a handoff (recommended consent flow)
The strongest and fastest way to get a writer's consent, and the one that involves no email from us at all.
POST /api/publish/v1/grants
{
"author": { "email": "jo@example.com", "pen_name": "Jo Ashworth" },
"return_url": "https://your-site.example/novelmint/done"
}
Redirect the writer's browser to the handoff_url you get back. They confirm in their own
Novelmint session and we send them to return_url.
What the writer sees, in order. The page loads before any sign-in: who sent their books, the exact titles, that nothing is published, and what the terms are. Signing in is asked for only at the point of deciding, and creating an account is free.
That order is deliberate. A demand for credentials arriving out of the blue, about books someone did not know had been submitted, is indistinguishable from a phishing attempt — and the list of titles is the one thing that lets a writer tell a real submission from an impersonation, so it has to be readable before they commit anything.
A writer with no account signs up at that point and is returned to this page, then to your
return_url, without losing their place.
201 Created
{
"grant_id": "…",
"state": "pending",
"handoff_url": "https://novelmint.ai/publish/claim/gr_…?return_to=https%3A%2F%2Fyour-site.example%2Fdone",
"expires_in": 3600
}
Send the writer to handoff_url as given. Your return_url is already encoded into it;
do not rebuild it or strip the query string.
return_urlis validated by exact origin against the website on your approved application. An unregistered origin is refused (422 return_url_not_allowed).- The handoff token is single-use and lives one hour. Long enough for a writer with no
account to sign up, find a magic link in their inbox and come back; short enough that a URL
sitting in browser history or a
Refererlog stops being useful the same day. If it does lapse, call this endpoint again for a fresh one. - Already-consented writer:
200with"state": "active"andhandoff_url: null. Nothing to confirm — just submit titles. - A request is already open:
200with a freshhandoff_urland"reissued": true. Call this again whenever you need to: the writer closing the tab is the ordinary reason, and the token may well have lapsed anyway. Re-issuing invalidates the previous link, so there is never more than one live claim credential for a writer. 403 author_blocked— the writer has blocked you. Do not retry.409 grant_not_pending— they already answered.
6.6 Read a submission
GET /api/publish/v1/books/{book_id}
{
"submission_id": "…", "book_id": "…", "external_ref": "MER-2291",
"status": "live", "reason_code": null, "mode": "on_behalf",
"title": "The Salt Road", "chapter_count": 41, "has_cover": true,
"content_rating": "T", "rating_status": "complete",
"chapters_classified": 41, "chapters_needing_review": 0,
"book_url": "https://novelmint.ai/read/jo-ashworth/the-salt-road",
"created_at": "2026-08-13T09:12:04Z", "updated_at": "2026-08-13T11:40:22Z"
}
chapter_count counts every chapter including prologues, which is what you mean when you
ask whether all forty arrived.
Poll this to reconcile. There are no webhooks in v1.
6.7 Content rating
We rate every chapter you send. You do not declare a rating, and we do not take one.
Each chapter is classified by us after the title goes live. The result decides two things: the book's rating, and whether that chapter is readable at all.
- Confidently rated → the chapter is published and readable.
- Not confidently rated → the chapter is held private and its owner is asked to confirm. We never publish prose on a guess about what is in it.
Classification is asynchronous, so POST .../publish always returns
"rating_status": "pending". Poll GET /books/{book_id}:
| Field | Meaning |
|---|---|
content_rating | The book's derived rating. null until chapters are classified |
rating_status | pending → in_progress → complete, or needs_review |
chapters_classified | How many are done |
chapters_needing_review | Held private, waiting on a human |
needs_review outranks complete: a book with one unrated chapter is not finished, and it is
the state worth chasing.
A book that classifies as Adult becomes manuscript-only and is unlisted — that is
manuscript_only in §6.4, arrived at from the prose rather than from anything you told us. If
your catalogue contains adult titles, they will be refused here whatever your contracts say.
7. Statuses
status | Meaning |
|---|---|
draft | Created, accumulating chapters. Invisible to everyone but you |
publishing | Transient, during the publish call |
held_pending_author | Ingested, waiting on the writer. Invisible to readers |
live | Published. book_url is populated |
rejected | Refused. reason_code says why |
A held title becomes rejected with reason_code: "grant_expired" if the writer never answers
within 30 days. Your content is not deleted — start a fresh request and the chapters are
still there.
8. Costs
Ingestion is free. Creating books and posting chapters debits nothing: no beats are extracted from finished prose, so none of the generation pipeline runs.
Per-chapter content classification (§6.7) runs after publish and is absorbed by Novelmint. It is the only model cost on this path and it is never billed to you.
There is no fee to join, per book, or per writer.
9. Worked example
A 41-chapter novel by a writer you represent, from nothing to live.
BASE=https://novelmint.ai/api/publish/v1
TOKEN=pub_test_…
# 1. Consent first, so the book is never held.
curl -sX POST "$BASE/grants" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"author":{"email":"jo@example.com","pen_name":"Jo Ashworth"},
"return_url":"https://your-site.example/done"}'
# -> redirect the writer's browser to handoff_url
# 2. The book shell.
BOOK=$(curl -sX POST "$BASE/books" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H "Idempotency-Key: MER-2291-create" \
-d '{"mode":"on_behalf","title":"The Salt Road","external_ref":"MER-2291",
"author":{"email":"jo@example.com","pen_name":"Jo Ashworth"}}' \
| jq -r .book_id)
# 3. Chapters, one at a time, in order.
curl -sX POST "$BASE/books/$BOOK/chapters" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Before the Rain","role":"prologue","content":"..."}'
# ... repeat for each chapter ...
# 4. Go live.
curl -sX POST "$BASE/books/$BOOK/publish" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"genre":"fantasy","credits_per_chapter":5,
"cover_url":"https://your-cdn.example/salt-road.jpg"}'
# 5. Reconcile.
curl -s "$BASE/books/$BOOK" -H "Authorization: Bearer $TOKEN"
10. Not in v1
Stated so you can plan around them rather than discover them.
- Webhooks. Poll
GET /books/{id}. - File upload / EPUB. Send structured chapters; you know where your chapter boundaries are and a parser only guesses.
- Deleting a published book. Withdrawal requests go to a human.
- Partial consent. A writer accepts everything you have sent them, or nothing.
- Bulk endpoints. One book per request, one chapter per request.
11. Support
partners@novelmint.ai for integration questions. Include your external_ref and
submission_id; both appear on every response for exactly this reason.
Not a partner yet? Apply here — approval is by hand, and tokens are minted from your dashboard once you are through.