# 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](/partners/docs).

This page is also available as [plain markdown](/partners/api.md).

---

## 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**.

```http
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:

```json
{ "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:

```json
{ "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

```http
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)

```json
{
  "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_behalf` with an `author.email` sends 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.

```http
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)

```json
{ "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).

```http
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**

```json
{ "chapter_id": "…", "chapter_number": 12, "content_changed": true }
```

---

### 6.4 Publish

```http
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**

```json
{
  "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)

```json
{
  "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.

```http
POST /api/publish/v1/grants
```

```json
{
  "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**

```json
{
  "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_url` is 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 `Referer` log stops being useful the same day. If it does
  lapse, call this endpoint again for a fresh one.
- **Already-consented writer:** `200` with `"state": "active"` and `handoff_url: null`. Nothing
  to confirm — just submit titles.
- **A request is already open:** `200` with a **fresh** `handoff_url` and `"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

```http
GET /api/publish/v1/books/{book_id}
```

```json
{
  "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.

```bash
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](/partners/apply) — approval is by hand, and tokens are minted
from your dashboard once you are through.
