# KDPBot API — OpenAPI 3.1.0
# Canonical copies: https://kdpbot.com/openapi.yaml | https://kdpbot.com/openapi.json
openapi: 3.1.0
info:
  title: KDPBot API
  version: 1.0.0
  summary: Generate complete, publication-ready books — outline, chapters, cover and export — from a single prompt.
  description: |
    The KDPBot API turns a book idea into a finished, publishable manuscript: outline, chapters,
    cover art, and export to PDF, DOCX or EPUB. It is the same API that powers
    [kdpbot.com](https://kdpbot.com), and it is designed to be driven by autonomous agents as
    readily as by a browser.

    ## The pipeline

    1. `POST /books/` — create a draft with a title, genre, length and audience.
    2. `POST /books/{id}/generate-outline/` — the model proposes chapters; edit them with
       `POST /books/{id}/update-outline/` or rework a few with `POST /books/{id}/regenerate-chapters/`.
    3. `POST /books/{id}/generate/` — queues the long-running write. Returns `202` immediately.
    4. `GET /books/{id}/generation-status/` — poll every few seconds until `generation_status` is
       `completed`. Pause, resume and stop endpoints steer a run in flight.
    5. `POST /books/{id}/generate-cover/` then `POST /books/{id}/select-cover/` — cover art.
    6. `POST /books/{id}/export_pdf/` (or `export_docx`, `export_epub`) — file bytes, ready for KDP.

    ## Conventions that will bite you if you skip them

    - **Trailing slashes are mandatory.** `/books` is not `/books/`; a slashless POST is redirected
      and loses its body.
    - **The server URL already ends in `/api`.** Paths in this document are relative to it, so the
      full URL for `/books/` is `https://bookgen-backend-5zuf.onrender.com/api/books/`.
    - **Almost no pagination.** List endpoints return a bare JSON array, never a `{count, next,
      results}` envelope. The single exception is `GET /proofreading-reports/{id}/suggestions/`,
      which is paged 20 at a time.
    - **Two export paths use underscores**: `export_pdf`, `export_docx`, `export_epub`, `preview_pdf`
      and `my_addons`. Everything else uses hyphens.
    - **Exports return binary**, not JSON. Check the status code before parsing.
    - **Ownership is scoped by 404.** Another account's id looks like a missing resource, not a
      forbidden one.
    - **Money is strings.** Plan and payment amounts are decimal strings; credit packages also expose
      authoritative integer cent fields.

    ## Authentication

    JWT bearer tokens. `POST /auth/login/` or `POST /auth/register/` returns a pair nested under
    `tokens`; send the access token as `Authorization: Bearer <access>`. Access tokens last 24 hours,
    refresh tokens 30 days, and refresh rotates on every use — store the new refresh token each time.
    Full walkthrough: <https://kdpbot.com/docs/authentication.md>.

    ## Errors and limits

    Every failure returns the same envelope, with a stable SCREAMING_SNAKE `error.code` to branch on:
    <https://kdpbot.com/docs/errors.md>. Rate-limit state is advertised through the `RateLimit-Limit`,
    `RateLimit-Remaining` and `RateLimit-Reset` response headers, and a 429 carries `Retry-After`:
    <https://kdpbot.com/docs/rate-limits.md>.

    ## More

    - Quickstart: <https://kdpbot.com/docs/quickstart.md>
    - Human-readable API reference: <https://kdpbot.com/docs/api.md>
    - Developer portal: <https://kdpbot.com/developers/>
    - Agent guidance: <https://kdpbot.com/llms.txt> and <https://kdpbot.com/agents.md>
    - This spec: <https://kdpbot.com/openapi.json> (JSON) and <https://kdpbot.com/openapi.yaml> (YAML)

    Questions: support@kdpbot.com, [Telegram](https://t.me/blabla_ecommerce), or
    [WhatsApp](https://wa.me/380994019521).


    ## Versioning

    The API is versioned in the URL path. The current version is **v1**, served at `https://kdpbot.com/api/v1`. The unversioned `/api/` prefix is a permanent alias of the current version, kept for existing clients; new integrations should pin `/api/v1/`.

    Every response carries `X-API-Version` and `X-API-Version-Latest`. When a version is scheduled for removal, its responses carry `Deprecation`, `Sunset` (RFC 8594) and `Link: <...>; rel="deprecation"` headers, with at least six months of notice.

    Full policy: https://kdpbot.com/docs/versioning.md
  termsOfService: https://kdpbot.com/terms
  contact:
    name: KDPBot AI Support
    email: support@kdpbot.com
    url: https://kdpbot.com
  license:
    name: KDPBot AI Terms of Service
    url: https://kdpbot.com/terms
  x-brand: KDPBot AI
  x-support:
    email: support@kdpbot.com
    telegram: https://t.me/blabla_ecommerce
    whatsapp: https://wa.me/380994019521
    academy: https://academy.kdpbot.com
  x-api-version: '1'
  x-api-version-latest: '1'
  x-versioning-policy: https://kdpbot.com/docs/versioning.md
externalDocs:
  description: Human-readable API reference, quickstart and error catalogue
  url: https://kdpbot.com/docs/api.md
servers:
- url: https://kdpbot.com/api/v1
  description: Production, version 1 (recommended). Same-origin with the web app.
- url: https://bookgen-backend-5zuf.onrender.com/api/v1
  description: Production origin, version 1.
- url: https://kdpbot.com/api
  description: Unversioned alias — always resolves to the current version. Kept for existing clients; new integrations
    should pin /api/v1.
security:
- bearerAuth: []
tags:
- name: Authentication
  description: Registration, login, Google sign-in, JWT refresh, profile and password management.
  externalDocs:
    description: Authentication guide
    url: https://kdpbot.com/docs/authentication.md
- name: Books
  description: 'The core resource: create a book, generate its outline and prose, commission cover art,
    and export the finished manuscript.'
  externalDocs:
    description: Quickstart
    url: https://kdpbot.com/docs/quickstart.md
- name: Chapters
  description: Read and edit individual chapters, rewrite one with AI, and manage its illustrations.
- name: Proofreading
  description: 'AI quality passes over a finished manuscript: a report of typed, severity-ranked issues,
    a preview of each proposed rewrite, and the calls that write accepted rewrites back into the chapters.'
  externalDocs:
    description: Endpoint reference
    url: https://kdpbot.com/docs/api.md
- name: Subscriptions
  description: Plans, the caller's current subscription, usage against quota, Stripe checkout and the
    billing portal.
- name: Credits
  description: 'Pay-as-you-go credits: packages, wallet balance, transaction ledger, and checkout for
    signed-in and guest buyers.'
- name: Add-ons
  description: Optional capabilities sold on top of a plan, such as Autopilot.
- name: Utility
  description: Dashboard statistics, public site settings, the support assistant, and the Stripe webhook
    receiver.
x-tagGroups:
- name: Getting started
  tags:
  - Authentication
- name: Book production
  tags:
  - Books
  - Chapters
  - Proofreading
- name: Billing
  tags:
  - Subscriptions
  - Credits
  - Add-ons
- name: Platform
  tags:
  - Utility
paths:
  /auth/register/:
    post:
      operationId: registerUser
      tags:
      - Authentication
      summary: Register a new account
      description: |-
        Creates an account and, in the default configuration, immediately returns a JWT token pair so the caller can start working without a second round trip.

        If the deployment requires e-mail confirmation, `tokens` is omitted and `email_verification_required: true` is returned instead — the user must click the link in the confirmation mail before logging in.

        An anti-abuse layer guards this endpoint: disposable mail domains and Gmail dot/plus aliases are rejected with 400, banned fingerprints and blacklisted IPs with 403, and a third account from the same browser fingerprint with 429.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: Account created. Contains the token pair unless e-mail verification is pending.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
              example:
                user:
                  id: 512
                  username: igor_wise
                  email: author@example.com
                  first_name: Igor
                  last_name: Wise
                  email_verified: false
                tokens:
                  access: eyJhbGciOi...
                  refresh: eyJhbGciOi...
                message: Account created successfully
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/login/:
    post:
      operationId: loginUser
      tags:
      - Authentication
      summary: Log in and obtain a token pair
      description: |-
        Authenticates with **either** `email` or `username`, plus `password`, and returns the user (with their nested subscription) together with a JWT access/refresh pair.

        The access token is valid for 24 hours; send it on every subsequent call as `Authorization: Bearer <access>`. Keep the refresh token (30 days) to mint new access tokens at `POST /auth/token/refresh/`.

        Note the shape: tokens are nested under `tokens`, unlike the refresh endpoint which returns them flat. See https://kdpbot.com/docs/authentication.md.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
      responses:
        '200':
          description: Authenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
              example:
                user:
                  id: 512
                  username: igor_wise
                  email: author@example.com
                  first_name: Igor
                  last_name: Wise
                  email_verified: true
                  avatar_url: null
                  is_staff: false
                  is_superuser: false
                  subscription:
                    id: 88
                    status: active
                    plan:
                      id: 3
                      name: Professional
                      slug: professional
                      features:
                        books_per_month: 30
                    current_period_end: '2026-10-01T00:00:00Z'
                tokens:
                  access: eyJhbGciOi...
                  refresh: eyJhbGciOi...
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/token/refresh/:
    post:
      operationId: refreshAccessToken
      tags:
      - Authentication
      summary: Exchange a refresh token for a new access token
      description: |-
        SimpleJWT's token-refresh view. Because refresh-token rotation is enabled, the response carries **both** a new `access` and a new `refresh` token — persist the new refresh token and drop the old one, or the next refresh will fail.

        Unlike the login/register responses these fields are flat, not nested under `tokens`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefreshRequest'
      responses:
        '200':
          description: A fresh token pair.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefreshResponse'
              example:
                access: eyJhbGciOi...
                refresh: eyJhbGciOi...
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          description: The refresh token is expired, malformed or already rotated. The user must sign
            in again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  code: TOKEN_NOT_VALID
                  message: Token is invalid or expired
                  status: 401
                  documentation_url: https://kdpbot.com/docs/errors.md
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/profile/:
    get:
      operationId: getUserProfile
      tags:
      - Authentication
      summary: Get the current user's profile
      description: Returns the authenticated account together with its nested subscription and the plan's
        feature map — the cheapest way for a client to learn who it is talking as and what that account
        is allowed to do. Accepts a JWT bearer token or a Django session cookie.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: The caller's profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserProfile'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    patch:
      operationId: updateUserProfile
      tags:
      - Authentication
      summary: Update the current user's profile
      description: |-
        Partially updates `first_name`, `last_name` and/or `email`. E-mail addresses must be unique across accounts; a collision returns 400.

        The response is intentionally narrower than the GET representation — it omits `email_verified`, `is_staff`, `is_superuser` and `subscription`, and adds `message`. Re-read `GET /auth/profile/` if you need the full object.
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProfileUpdateRequest'
      responses:
        '200':
          description: Profile updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileUpdateResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/google/:
    post:
      operationId: loginWithGoogle
      tags:
      - Authentication
      summary: Sign in or sign up with Google
      description: Exchanges a Google OAuth2 access token for KDPBot credentials. The server verifies
        the token against Google's userinfo endpoint, then links it to an existing account with the same
        e-mail address or provisions a new one. The response shape is identical to `POST /auth/login/`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GoogleLoginRequest'
      responses:
        '200':
          description: Authenticated through Google.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/logout/:
    post:
      operationId: logoutUser
      tags:
      - Authentication
      summary: Log out
      description: Courtesy endpoint that always succeeds. Refresh tokens are **not** blacklisted server-side,
        so the real logout is the client discarding both tokens. Treat a stolen refresh token as valid
        until it expires and rotate the account password if one leaks.
      security: []
      responses:
        '200':
          description: Acknowledged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/change-password/:
    post:
      operationId: changePassword
      tags:
      - Authentication
      summary: Change the current user's password
      description: Verifies `current_password` and replaces it with `new_password` (minimum eight characters).
        Existing tokens are not revoked, so already-issued access tokens keep working until they expire.
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePasswordRequest'
      responses:
        '200':
          description: Password changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/forgot-password/:
    post:
      operationId: requestPasswordReset
      tags:
      - Authentication
      summary: Request a password-reset e-mail
      description: Sends a reset link containing a `uid` and a `token` to the supplied address. The address
        is lower-cased and trimmed before lookup. Complete the flow at `POST /auth/reset-password/{uid}/{token}/`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ForgotPasswordRequest'
      responses:
        '200':
          description: If the address exists, a reset mail has been sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/reset-password/{uid}/{token}/:
    post:
      operationId: confirmPasswordReset
      tags:
      - Authentication
      summary: Complete a password reset
      description: Sets a new password using the `uid` and `token` from the reset e-mail. Both segments
        are single-use; a consumed or expired token returns 400.
      security: []
      parameters:
      - name: uid
        in: path
        required: true
        description: URL-safe base64 encoding of the user id, taken verbatim from the reset link.
        schema:
          type: string
          minLength: 1
        example: MTIz
      - name: token
        in: path
        required: true
        description: Single-use reset token from the same link.
        schema:
          type: string
          minLength: 1
        example: c1x2y3-9f0a1b2c3d4e5f
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordRequest'
      responses:
        '200':
          description: Password reset.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/verify-email/{uid}/{token}/:
    get:
      operationId: verifyEmail
      tags:
      - Authentication
      summary: Confirm an e-mail address
      description: Marks the account's address as verified using the link mailed at registration. This
        is a GET because it is opened directly from a mail client.
      security: []
      parameters:
      - name: uid
        in: path
        required: true
        description: URL-safe base64 encoding of the user id.
        schema:
          type: string
          minLength: 1
        example: MTIz
      - name: token
        in: path
        required: true
        description: Single-use verification token.
        schema:
          type: string
          minLength: 1
        example: c1x2y3-9f0a1b2c3d4e5f
      responses:
        '200':
          description: Address verified.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/subscriptions/plans/:
    get:
      operationId: listSubscriptionPlans
      tags:
      - Subscriptions
      summary: List subscription plans
      description: |-
        Every active tier with its price and feature map. Public — no token needed — so it is the right source for a pricing page or for an agent deciding what an account would need to upgrade to.

        Returns a bare JSON array. Prices are decimal strings, not numbers. Feature keys such as `books_per_month` and `export_formats` are the same keys the quota gates enforce.
      security: []
      responses:
        '200':
          description: All active plans, ordered by `sort_order`.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SubscriptionPlan'
              example:
              - id: 1
                name: Free
                slug: free
                price_monthly: '0.00'
                price_yearly: '0.00'
                description: Try KDPBot at no cost
                is_active: true
                sort_order: 0
                features:
                  books_per_month: 1
                  ai_images_per_month: 0
                  export_formats:
                  - pdf
                  watermark: true
                  max_word_count_per_book: 10000
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/subscriptions/current/:
    get:
      operationId: getCurrentSubscription
      tags:
      - Subscriptions
      summary: Get the caller's subscription, usage and limits
      description: |-
        One call that answers 'what can this account do right now': the subscription record, the current billing period's consumption counters, and the plan's raw limit map.

        Check this before starting expensive work — comparing `usage.books_created` against `limits.books_per_month` avoids a 402 halfway through a generation run.

        Returns 404 when the account has no subscription row at all (rare; new accounts are provisioned onto the Free plan).
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Subscription, usage and limits.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CurrentSubscription'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/subscriptions/usage/:
    get:
      operationId: getSubscriptionUsage
      tags:
      - Subscriptions
      summary: Get usage for the current and recent periods
      description: The live quota row plus up to six previous billing periods, newest first — enough to
        chart consumption or to detect that a period just rolled over and the allowance reset.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Current period plus history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageHistory'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/subscriptions/book-creation-options/:
    get:
      operationId: getBookCreationOptions
      tags:
      - Subscriptions
      summary: Check whether a new book can be started
      description: Resolves the two funding sources — plan allowance and purchased credits — into a single
        yes/no plus a recommendation. Call this before `POST /books/` to give the user an accurate prompt
        instead of letting the create call fail with 402.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Availability across subscription quota and credits.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookCreationOptions'
              example:
                can_create: true
                subscription_available: true
                subscription_remaining: 7
                credits_available: true
                credits_balance: 5
                suggested_source: subscription
                max_words: 80000
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/subscriptions/checkout/:
    post:
      operationId: createSubscriptionCheckout
      tags:
      - Subscriptions
      summary: Start a plan purchase or upgrade
      description: |-
        Creates a Stripe Checkout Session for a paid plan and returns its `sessionId` for `stripe.redirectToCheckout`.

        When the account already holds a paid subscription the change may instead be applied immediately with proration — that branch returns `upgraded: true` plus `redirect_url` and no `sessionId`. Handle both shapes.

        The `free` slug is not purchasable and returns 400.
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubscriptionCheckoutRequest'
      responses:
        '200':
          description: Either a Checkout session or a completed instant upgrade.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SubscriptionCheckoutResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/subscriptions/portal/:
    post:
      operationId: createBillingPortalSession
      tags:
      - Subscriptions
      summary: Open the Stripe billing portal
      description: Returns a single-use Stripe Customer Portal URL where the user can change their payment
        method, download invoices or cancel. The link is short-lived — redirect immediately rather than
        storing it.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Portal URL.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PortalResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/subscriptions/payments/:
    get:
      operationId: listPaymentHistory
      tags:
      - Subscriptions
      summary: List recent payments
      description: The twenty most recent invoices for the account, newest first, each with a link to
        its Stripe-hosted PDF.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Payment history.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PaymentHistory'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/addons/available/:
    get:
      operationId: listAvailableAddons
      tags:
      - Add-ons
      summary: List purchasable add-ons
      description: Capabilities sold on top of a plan, such as Autopilot (fully automated end-to-end book
        production) and the children's-book generator. Public; returns a bare array. Prices are decimal
        strings, and an add-on may be billed monthly, per use, or both.
      security: []
      responses:
        '200':
          description: Active add-ons.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Addon'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/addons/my_addons/:
    get:
      operationId: listMyAddons
      tags:
      - Add-ons
      summary: List the caller's active add-ons
      description: |-
        Add-ons currently attached to the account, with per-period usage counters and whether any uses remain.

        **Path quirk:** this route uses an underscore (`my_addons`), not a hyphen, because the underlying action has no explicit `url_path` and inherits the Python method name.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: The caller's add-ons.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/UserAddon'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/addons/checkout/:
    post:
      operationId: createAddonCheckout
      tags:
      - Add-ons
      summary: Purchase an add-on
      description: Creates a Stripe Checkout Session for one add-on. `billing_type` picks between a monthly
        subscription and a single metered use.
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddonCheckoutRequest'
      responses:
        '200':
          description: Checkout session created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutSession'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/credits/packages/:
    get:
      operationId: listCreditPackages
      tags:
      - Credits
      summary: List credit packages
      description: |-
        Pay-as-you-go bundles for users who do not want a subscription: each credit funds one book up to `words_per_book`. Public, ordered by `display_order`, returned as a bare array.

        Unlike plan prices, these expose both a float dollar amount (`price`, `price_per_book`) and the authoritative integer cent amount (`price_cents`, `price_per_book_cents`) — prefer the cents when doing arithmetic.
      security: []
      responses:
        '200':
          description: Active packages.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/CreditPackage'
              example:
              - id: 2
                name: Starter Pack
                slug: starter-5
                credits: 5
                price: 39.0
                price_cents: 3900
                price_per_book: 7.8
                price_per_book_cents: 780
                savings_percent: 20
                words_per_book: 40000
                is_popular: true
                is_active: true
                display_order: 1
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/credits/balance/:
    get:
      operationId: getCreditBalance
      tags:
      - Credits
      summary: Get the credit balance
      description: 'The caller''s wallet: credits available now, lifetime purchased and lifetime spent.
        The wallet row is created on first read, so a brand-new account returns zeroes rather than a 404.'
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Current balance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreditBalance'
              example:
                id: 91
                balance: 4
                total_purchased: 5
                total_used: 1
                created_at: '2026-08-01T09:15:00Z'
                updated_at: '2026-09-01T12:00:00Z'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/credits/transactions/:
    get:
      operationId: listCreditTransactions
      tags:
      - Credits
      summary: List credit transactions
      description: Ledger of purchases, book spends, refunds and bonuses, newest first. Each row carries
        the resulting `balance_after`, so the history reconciles against `GET /auth/credits/balance/`
        without extra maths.
      security:
      - bearerAuth: []
      parameters:
      - name: limit
        in: query
        required: false
        description: Maximum rows to return. Values above 100 are clamped to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
        example: 20
      responses:
        '200':
          description: Transaction history.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/CreditTransaction'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/credits/checkout/:
    post:
      operationId: createCreditCheckout
      tags:
      - Credits
      summary: Buy a credit package
      description: Creates a Stripe Checkout Session for a credit bundle on behalf of the signed-in user.
        Credits land on the wallet when Stripe confirms the payment through the webhook, not when this
        call returns.
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreditCheckoutRequest'
      responses:
        '200':
          description: Checkout session created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutSession'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/credits/guest-checkout/:
    post:
      operationId: createGuestCreditCheckout
      tags:
      - Credits
      summary: Buy credits without an account
      description: Anonymous purchase path. Stripe collects the buyer's e-mail on the Checkout page; once
        payment succeeds the webhook provisions an account, credits it, and mails a magic sign-in link.
        No token is required.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GuestCreditCheckoutRequest'
      responses:
        '200':
          description: Checkout session created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutSession'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/:
    get:
      operationId: listBooks
      tags:
      - Books
      summary: List the caller's books
      description: |-
        Every book owned by the authenticated user, most recently updated first, using a lightweight representation: no chapter bodies, but with database-annotated `chapters_count` and `word_count`.

        **No pagination.** The API returns a bare JSON array, not a `{count, next, results}` envelope — do not look for cursors that are not there.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Books owned by the caller.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BookListItem'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      operationId: createBook
      tags:
      - Books
      summary: Create a book
      description: |-
        Creates a draft book — the first step of the pipeline. Only `title` is strictly required; everything else falls back to sensible defaults (10 chapters, 40 000 words, 6×9 trim, English, `fiction`).

        The book allowance is *checked* here (402 when exhausted) but *consumed* only when a generation run completes, so creating and deleting drafts costs nothing.

        Unknown keys are dropped silently rather than rejected. Watch two aliasing traps: camelCase spellings such as `chapterCount` are accepted, while `tone`, `writingStyle` and `pov` are remapped onto internal fields that are never persisted — use `writing_style` for anything that must survive.

        Typical flow: create → `POST /books/{id}/generate-outline/` → review → `POST /books/{id}/generate/` → poll → export.
      security:
      - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookWriteRequest'
      responses:
        '201':
          description: Book created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/:
    get:
      operationId: getBook
      tags:
      - Books
      summary: Get one book with all chapters
      description: 'The complete book: metadata, every chapter *including its full text*, the generation
        progress record and derived cover/image URLs. This response can be large for a finished manuscript
        — prefer `GET /books/` when you only need the list view, and `GET /books/{id}/generation-status/`
        when you only need progress.'
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book. Ids belonging to another account return 404.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: The book.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    patch:
      operationId: updateBook
      tags:
      - Books
      summary: Partially update a book
      description: Changes only the supplied fields. Changing `target_word_count` or `chapter_count` while
        chapters already exist re-derives every chapter's `target_word_count` as `target_word_count //
        chapter_count`, so an outline stays internally consistent.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to update.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookWriteRequest'
      responses:
        '200':
          description: Updated book.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    put:
      operationId: replaceBook
      tags:
      - Books
      summary: Replace a book
      description: Full-representation update. Every writable field you omit is reset to its default,
        so `PATCH` is almost always the safer choice.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to replace.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BookWriteRequest'
      responses:
        '200':
          description: Replaced book.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    delete:
      operationId: deleteBook
      tags:
      - Books
      summary: Delete a book
      description: Permanently removes the book and cascades to its chapters, images, saved covers and
        progress record. Deleting does **not** refund a consumed book quota or credit.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to delete.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '204':
          description: Deleted. Empty body.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/dropdown/:
    get:
      operationId: listBooksDropdown
      tags:
      - Books
      summary: List book ids and titles
      description: Minimal `{id, title}` rows, newest first — for populating selectors without pulling
        the full list representation.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Id/title pairs.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/BookDropdownItem'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/demo/:
    get:
      operationId: getDemoBook
      tags:
      - Books
      summary: Get the public demo book
      description: A finished sample book, served without authentication, that the marketing site uses
        to showcase output quality. Useful for exercising client code against a realistic `Book` payload
        before you have credentials.
      security: []
      responses:
        '200':
          description: The demo book.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/generate-outline/:
    post:
      operationId: generateBookOutline
      tags:
      - Books
      summary: Generate the chapter outline
      description: |-
        Asks the model for a chapter-by-chapter outline and materialises it: **all existing chapters are deleted**, fresh `Chapter` rows are created with titles and summaries, each gets `target_word_count // chapter_count` words, the book moves to status `outline`, and the progress record is reset.

        This call is synchronous and can take tens of seconds — set a generous client timeout. It takes no request body.

        Destructive by design: if the user has edited chapter text, re-running this discards it. To adjust individual chapters instead, use `POST /books/{id}/regenerate-chapters/`, and to persist manual edits use `POST /books/{id}/update-outline/`.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to outline.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: The book with its freshly created chapters and reset progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/update-outline/:
    post:
      operationId: updateBookOutline
      tags:
      - Books
      summary: Save edited outline entries
      description: |-
        Persists user edits to chapter titles, summaries and title options. Rows are matched by `chapter_number`; numbers that do not exist on the book are skipped rather than created, and `updated_count` reports how many actually changed.

        Supplying `title_options` clears the AI's alternatives for that chapter, on the assumption the user has settled on their own title.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book whose outline is being edited.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateOutlineRequest'
      responses:
        '200':
          description: Outline saved, with the refreshed book.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpdateOutlineResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/regenerate-chapters/:
    post:
      operationId: regenerateOutlineChapters
      tags:
      - Books
      summary: Regenerate selected outline chapters
      description: Rewrites the outline entries for the listed chapter numbers, optionally cascading into
        the chapters that follow so the narrative stays coherent, and optionally steered by a free-text
        `guidance` note. Returns the updated outline without touching chapter bodies.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegenerateChaptersRequest'
      responses:
        '200':
          description: The revised outline.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegenerateChaptersResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/generate/:
    post:
      operationId: generateBookContent
      tags:
      - Books
      summary: Start generating all chapter content
      description: |-
        Queues the long-running job that writes every chapter, sets the book to `generating` and returns **202 Accepted** immediately — the work itself takes minutes to tens of minutes. Track it with `GET /books/{id}/generation-status/`, and steer it with the pause, resume and stop endpoints.

        Preconditions: the book must already have an outline (chapters), and no run may be active for it. Quota is checked here and consumed on completion.

        **Path note:** the route is `/books/{id}/generate/`. The `generate_content` path that older documentation advertises does not exist.

        `mode` in the response tells you how the work was dispatched: `celery` normally, or `threading` if the broker was unreachable and the server fell back to an in-process thread (which does not survive a restart).

        **Already running:** a book whose run has actually started answers **400** with the bare body `{"error": "Generation already in progress for this book"}` — not 409, and not the standard error envelope. Poll `GET /books/{id}/generation-status/` instead of retrying.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to generate.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '202':
          description: Generation queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerationStartResponse'
              example:
                message: Generation started
                book_id: 1042
                task_id: 6f1c2b7a-2c1f-4a1e-9c34-7a2b1d0e5f88
                mode: celery
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          description: The book cannot be generated in its current state — it has no outline, or a run is already active
            for it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                already_running:
                  summary: A generation run is already active
                  description: Returned verbatim by the view, so it carries a bare string under `error` rather than the
                    envelope object.
                  value:
                    error: Generation already in progress for this book
                validation:
                  summary: The request could not be processed
                  value:
                    error:
                      code: VALIDATION_ERROR
                      message: The request body failed validation.
                      status: 400
                      documentation_url: https://kdpbot.com/docs/errors.md
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/generation-status/:
    get:
      operationId: getGenerationStatus
      tags:
      - Books
      summary: Poll generation progress
      description: |-
        The progress record plus a per-chapter completion snapshot. Poll it every few seconds while `generation_status` is `running`; stop when it reaches `completed`, `stopped` or `error`.

        `percentage` and `progress_percentage` carry the same value. Returns 404 when the book has never been generated — that is the expected answer for a fresh draft, not a fault.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book being generated.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: Current progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerationStatus'
              example:
                id: 771
                book: 1042
                total_chapters: 12
                completed_chapters: 4
                current_chapter: 5
                status_message: Writing chapter 5 of 12
                percentage: 33
                generation_status: running
                progress_percentage: 33
                current_step: Generating chapter 5
                outline:
                - title: Why Habits Stick
                  chapter_number: 1
                  word_count: 3320
                  has_content: true
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/pause-generation/:
    post:
      operationId: pauseGeneration
      tags:
      - Books
      summary: Pause content generation
      description: |-
        Suspends the run after the chapter in flight finishes. Progress is preserved; resume with `POST /books/{id}/resume-generation/`.

        Takes no request body. Returns 400 when no run is active for the book.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book whose generation run is being controlled.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: New generation state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/resume-generation/:
    post:
      operationId: resumeGeneration
      tags:
      - Books
      summary: Resume content generation
      description: |-
        Continues a paused run from the first chapter that has no content. Chapters already written are not rewritten.

        Takes no request body. Returns 400 when no run is active for the book.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book whose generation run is being controlled.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: New generation state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/stop-generation/:
    post:
      operationId: stopGeneration
      tags:
      - Books
      summary: Stop content generation
      description: |-
        Cancels the run for good. Chapters written so far are kept, the book stays where it is, and restarting means calling `POST /books/{id}/generate/` again.

        Takes no request body. Returns 400 when no run is active for the book.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book whose generation run is being controlled.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: New generation state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/generate-cover/:
    post:
      operationId: generateBookCover
      tags:
      - Books
      summary: Generate AI cover variants
      description: |-
        Produces two cover images from the book's metadata plus any art direction you supply, and returns their URLs. Pick one with `POST /books/{id}/select-cover/`.

        Costs two AI-image credits from the monthly allowance. Blocked on the Free plan with 402 `free_plan_restriction`, and returns 402 `quota_exceeded` when fewer than two image credits remain. Staff accounts bypass both gates.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to design a cover for.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateCoverRequest'
      responses:
        '200':
          description: Two cover variants.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerateCoverResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/select-cover/:
    post:
      operationId: selectBookCover
      tags:
      - Books
      summary: Choose a generated cover variant
      description: Promotes one of the two generated variants to the book's cover. Selecting a variant
        that was never generated returns 400.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SelectCoverRequest'
      responses:
        '200':
          description: Cover selected.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SelectCoverResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/export_pdf/:
    post:
      operationId: exportBookPdf
      tags:
      - Books
      summary: Export the book as PDF
      description: |-
        Renders the finished manuscript to a print-ready PDF sized to the book's `trim_size`. Free-plan exports carry a watermark, applied server-side and not overridable.

        **This returns file bytes, not JSON** — the body is `application/pdf` with a `Content-Disposition: attachment` header. Only failures come back as JSON, so check the status code before parsing.

        **Path note:** the segment uses an underscore (`export_pdf`), not a hyphen.

        A successful export marks the book as exported and records `pdf` in its `export_formats`.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to export.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportRequest'
        description: Optional renderer settings. Omit for defaults.
      responses:
        '200':
          description: The generated PDF file.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Content-Disposition:
              description: Attachment header carrying the suggested filename, derived from the book title.
              schema:
                type: string
              example: attachment; filename="The Art of Habit Formation.pdf"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
                description: PDF file bytes.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/export_docx/:
    post:
      operationId: exportBookDocx
      tags:
      - Books
      summary: Export the book as DOCX
      description: |-
        Renders the manuscript to a Word document for further editing. Gated by plan: accounts whose plan does not include DOCX get 402 with the permitted `allowed_formats`.

        **This returns file bytes, not JSON** — the body is `application/vnd.openxmlformats-officedocument.wordprocessingml.document` with a `Content-Disposition: attachment` header. Only failures come back as JSON, so check the status code before parsing.

        **Path note:** the segment uses an underscore (`export_docx`), not a hyphen.

        A successful export marks the book as exported and records `docx` in its `export_formats`.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to export.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportRequest'
        description: Optional renderer settings. Omit for defaults.
      responses:
        '200':
          description: The generated DOCX file.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Content-Disposition:
              description: Attachment header carrying the suggested filename, derived from the book title.
              schema:
                type: string
              example: attachment; filename="The Art of Habit Formation.docx"
          content:
            application/vnd.openxmlformats-officedocument.wordprocessingml.document:
              schema:
                type: string
                format: binary
                description: DOCX file bytes.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/export_epub/:
    post:
      operationId: exportBookEpub
      tags:
      - Books
      summary: Export the book as EPUB
      description: |-
        Renders the manuscript to a reflowable EPUB for e-readers. Gated by plan in the same way as DOCX.

        **This returns file bytes, not JSON** — the body is `application/epub+zip` with a `Content-Disposition: attachment` header. Only failures come back as JSON, so check the status code before parsing.

        **Path note:** the segment uses an underscore (`export_epub`), not a hyphen.

        A successful export marks the book as exported and records `epub` in its `export_formats`.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to export.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportRequest'
        description: Optional renderer settings. Omit for defaults.
      responses:
        '200':
          description: The generated EPUB file.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Content-Disposition:
              description: Attachment header carrying the suggested filename, derived from the book title.
              schema:
                type: string
              example: attachment; filename="The Art of Habit Formation.epub"
          content:
            application/epub+zip:
              schema:
                type: string
                format: binary
                description: EPUB file bytes.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/preview_pdf/:
    post:
      operationId: previewBookPdf
      tags:
      - Books
      summary: Render a short PDF preview
      description: |-
        Renders only the opening of the book so the user can check typography and layout without paying for a full export. Returns PDF bytes, not JSON.

        **Path note:** underscore, not hyphen.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to preview.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExportRequest'
        description: Optional renderer settings. Omit for defaults.
      responses:
        '200':
          description: Preview PDF bytes.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/preview-data/:
    get:
      operationId: getBookPreviewData
      tags:
      - Books
      summary: Get data for the flip-book preview
      description: Paginated page fragments, cover URLs and trim size, shaped for the interactive 3-D
        preview in the web app. Unlike `preview_pdf` this is JSON, so it is the one to use if you are
        building your own viewer.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to preview.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: Preview payload.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookPreviewData'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/duplicate/:
    post:
      operationId: duplicateBook
      tags:
      - Books
      summary: Duplicate a book
      description: Deep-copies the book and all its chapters into a new draft owned by the same user —
        the usual way to fork a finished manuscript before trying a different angle. Takes no body.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book to copy.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '201':
          description: The new copy.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/reorder-chapters/:
    post:
      operationId: reorderBookChapters
      tags:
      - Books
      summary: Reorder chapters
      description: Applies a complete new ordering in a single transaction (numbers are parked as negatives
        first, so the unique constraint never trips mid-swap). Send every chapter of the book, not just
        the moved ones.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book whose chapters are being reordered.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReorderChaptersRequest'
      responses:
        '200':
          description: New ordering applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/track-editor/:
    post:
      operationId: trackEditorUsage
      tags:
      - Books
      summary: Report editor usage
      description: Telemetry ping from the manuscript editor. `open` starts a session and bumps the session
        counter; `heartbeat` and `close` add `duration` seconds to the book's cumulative editor time.
        Purely analytical — it never changes manuscript content.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book being edited.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TrackEditorRequest'
      responses:
        '200':
          description: Telemetry recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chapters/:
    get:
      operationId: listChapters
      tags:
      - Chapters
      summary: List every chapter the caller owns
      description: |-
        Returns chapters across **all** of the caller's books, with full text. There is no `?book=` filter — either filter client-side on the `book` field, or read `GET /books/{id}/` and use its embedded `chapters` array, which is usually what you want.

        Bare array; no pagination. This response can be very large for an established account.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: All chapters owned by the caller.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Chapter'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chapters/{id}/:
    get:
      operationId: getChapter
      tags:
      - Chapters
      summary: Get one chapter
      description: The chapter with its full text, outline summary, word counts and generation flag.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: The chapter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Chapter'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    patch:
      operationId: updateChapter
      tags:
      - Chapters
      summary: Partially update a chapter
      description: |-
        The editor's save path — send `{"content": "..."}` to persist manuscript edits.

        `actual_word_count` and `word_count` are recomputed server-side from `content` on every save, so any value you send for them is ignored, as is `ai_summary`.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter to update.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChapterWriteRequest'
      responses:
        '200':
          description: Updated chapter with recomputed word counts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Chapter'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableContent'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    put:
      operationId: replaceChapter
      tags:
      - Chapters
      summary: Replace a chapter
      description: Full-representation update. Prefer `PATCH` unless you are deliberately resetting omitted
        fields.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter to replace.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChapterWriteRequest'
      responses:
        '200':
          description: Replaced chapter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Chapter'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
    delete:
      operationId: deleteChapter
      tags:
      - Chapters
      summary: Delete a chapter
      description: Removes the chapter and its images. The book's `chapter_count` is not adjusted automatically,
        so update the book if the planned length changed.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter to delete.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '204':
          description: Deleted. Empty body.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chapters/{id}/regenerate/:
    post:
      operationId: regenerateChapter
      tags:
      - Chapters
      summary: Rewrite one chapter with AI
      description: |-
        Regenerates this chapter's prose from its outline summary and the book's style settings, overwriting the existing text and setting `is_generated` to true.

        Synchronous: the call blocks until the model finishes, which can take a minute or more for a long chapter. Use a generous timeout, and take a copy of the old text first if the user might want it back.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter to rewrite.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: The rewritten chapter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Chapter'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chapters/{id}/generate-image/:
    post:
      operationId: generateChapterImage
      tags:
      - Chapters
      summary: Generate an AI illustration
      description: |-
        Creates an illustration for the chapter and attaches it. Omit `prompt` to let the server derive art direction from the chapter text.

        Consumes one AI-image credit. Blocked on the Free plan with 402 `free_plan_restriction`; staff accounts bypass the gate.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter to illustrate.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateChapterImageRequest'
      responses:
        '200':
          description: The generated image.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerateChapterImageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chapters/{id}/images/:
    get:
      operationId: listChapterImages
      tags:
      - Chapters
      summary: List a chapter's images
      description: Every image attached to the chapter, generated or uploaded, with its prompt and insertion
        position.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: The chapter's images.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ChapterImage'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chapters/{id}/upload-image/:
    post:
      operationId: uploadChapterImage
      tags:
      - Chapters
      summary: Upload an image to a chapter
      description: |-
        Attaches a user-supplied illustration. Send `multipart/form-data` with an `image` part.

        Accepted types are JPEG, PNG, GIF and WEBP; the limit is 5 MB. Anything else returns 400. Uploads do not consume AI-image quota.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter to attach the image to.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/UploadChapterImageRequest'
      responses:
        '200':
          description: The stored image.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChapterImage'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chapters/{id}/images/{image_id}/:
    delete:
      operationId: deleteChapterImage
      tags:
      - Chapters
      summary: Delete a chapter image
      description: Detaches and deletes one image. Images belonging to a different chapter return 404.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the chapter that owns the image.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      - name: image_id
        in: path
        required: true
        description: Numeric id of the image to delete.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 77
      responses:
        '200':
          description: Image deleted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MessageResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /books/{id}/proofreading/:
    post:
      operationId: startProofreading
      tags:
      - Proofreading
      summary: Start an AI proofreading pass
      description: |-
        Queues an AI quality pass over every chapter of a finished book and returns **202 Accepted** with the id of the report a worker will fill in. Poll `GET /proofreading-reports/{id}/` — or `GET /books/{id}/proofreading/latest/` — until `status` reaches `completed`; a pass normally takes 5–15 minutes.

        The book must be in `completed` status. A draft, or one still generating, is refused with **400** and the verbatim body `{"error": "Book must be completed before checking", "current_status": …}`.

        **Content-hash cache.** The server hashes the manuscript before starting. If the newest un-archived report was produced from byte-identical content, no new pass runs: the call answers **200** with `cached: true` and the existing `report_id`. When the content has changed, the book's earlier reports are archived and a fresh pass is queued.

        Proofreading checks are free and unmetered during the current beta — only *applying* suggestions draws on the `proofreading_edits` allowance.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the completed book to check.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: A cached report covering identical content was reused; nothing was queued and no
            quota was consumed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingRunCached'
              example:
                report_id: 771
                status: completed
                cached: true
                message: Using cached analysis (book content unchanged). No quota consumed.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '202':
          description: A new pass was queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingRunQueued'
              example:
                task_id: 6f1c2b7a-2c1f-4a1e-9c34-7a2b1d0e5f88
                report_id: 772
                status: queued
                message: Check started. This may take 5-15 minutes.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          description: The book is not in `completed` status, so there is nothing stable to check.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Book must be completed before checking
                current_status: generating
                message: Only completed books can be checked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: A pass for this book is already `queued` or `processing`. Poll the report named in
            the body instead of starting another.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingRunConflict'
              example:
                error: Check already running
                report_id: 772
                status: processing
                message: Please wait for the current check to complete
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The pass could not be started. Also the answer for an unknown or other-account book
            id — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to start proofreading
                message: No Book matches the given query.
  /books/{id}/proofreading/latest/:
    get:
      operationId: getLatestProofreadingReport
      tags:
      - Proofreading
      summary: Newest completed report for a book
      description: |-
        The most recent report whose `status` is `completed`, together with every suggestion it produced — the cheapest way to pick a finished pass back up without holding on to a report id.

        Reports still `queued` or `processing` are ignored, so a book whose only pass is still running answers **404** with the verbatim body `{"error": "No report found", "message": …}`. Archived reports are still eligible; only completion is required.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book whose latest report you want.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: The report and its suggestions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingLatestReport'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: The book exists but has no completed report yet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: No report found
                message: No completed proofreading reports for this book
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The report could not be read. Also the answer for an unknown or other-account book
            id — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to get latest report
                message: No Book matches the given query.
  /books/{id}/proofreading-reports/:
    get:
      operationId: listProofreadingReports
      tags:
      - Proofreading
      summary: List a book's proofreading reports
      description: |-
        Every report ever produced for the book — archived and unarchived, finished and in-flight — newest first, as a bare JSON array with no pagination envelope.

        Entries are summaries: they carry the scores and issue counts but **not** the `suggestions` array, and not `fiction_analysis` / `nonfiction_analysis`. Fetch one with `GET /proofreading-reports/{id}/` for the full record.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the book whose reports you want.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 1042
      responses:
        '200':
          description: Report summaries, newest first.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ProofreadingReportSummary'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The list could not be read. Also the answer for an unknown or other-account book
            id — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to get reports list
                message: No Book matches the given query.
  /proofreading-reports/{id}/:
    get:
      operationId: getProofreadingReport
      tags:
      - Proofreading
      summary: Read one proofreading report
      description: |-
        The full report: status, the 0–100 `overall_score`, the genre-specific analysis block, token and cost accounting, and every suggestion inline under `suggestions`.

        Exactly one of `fiction_analysis` and `nonfiction_analysis` is populated — whichever matches the book's genre; the other stays `{}`. `summary` is a copy of that block's `analysis_text`.

        Poll this while `status` is `queued` or `processing`. `duration_seconds` is `null` until the pass finishes.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the proofreading report.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 771
      responses:
        '200':
          description: The report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingReport'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The report could not be read. Also the answer for an unknown or other-account report
            id — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to get report
                message: No ProofreadingReport matches the given query.
  /proofreading-reports/{id}/suggestions/:
    get:
      operationId: listProofreadingSuggestions
      tags:
      - Proofreading
      summary: List a report's suggestions, filtered and paged
      description: |-
        The report's suggestions, orderable by nothing but the server's own ordering (severity descending, then chapter number, then creation time), narrowed by the query filters below.

        **This is the one paginated endpoint in the API.** It returns a `{count, next, previous, results}` envelope rather than the bare array every other list endpoint returns — 20 per page by default, 100 at most. `next` and `previous` are absolute URLs or `null`. Use `GET /proofreading-reports/{id}/` instead if you want all of them in one unpaged shot.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the proofreading report.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 771
      - name: issue_type
        in: query
        required: false
        description: Return only suggestions of this type.
        schema:
          $ref: '#/components/schemas/ProofreadingIssueType'
      - name: severity
        in: query
        required: false
        description: Return only suggestions at this severity.
        schema:
          $ref: '#/components/schemas/ProofreadingSeverity'
      - name: is_applied
        in: query
        required: false
        description: Filter on whether the suggestion has been applied. Compared case-insensitively against
          the literal `true`; **any** other value — `1`, `yes`, `False` — filters for *not applied*.
        schema:
          type: string
          enum:
          - 'true'
          - 'false'
        example: 'false'
      - name: page
        in: query
        required: false
        description: '1-based page number. A page past the end raises DRF''s `NotFound` inside the view''s
          own exception handler, so it surfaces as a 500 carrying `"message": "Invalid page."`.'
        schema:
          type: integer
          minimum: 1
          default: 1
      - name: page_size
        in: query
        required: false
        description: Results per page. Values above 100 are clamped to 100.
        schema:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
      responses:
        '200':
          description: One page of suggestions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingSuggestionPage'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The suggestions could not be read — including a `page` past the end, which reports
            `Invalid page.` here rather than 404. Also the answer for an unknown or other-account report
            id — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to get suggestions
                message: No ProofreadingReport matches the given query.
  /proofreading-reports/{id}/preview/:
    post:
      operationId: previewProofreadingSuggestions
      tags:
      - Proofreading
      summary: Preview AI rewrites without applying them
      description: |-
        Asks the model for the replacement text each selected suggestion would produce and returns it side by side with the original — **nothing is written to any chapter**, no suggestion is marked applied, and the `proofreading_edits` allowance is untouched. This is the diff view to show a human before calling `apply/`.

        Ids that do not belong to this report are skipped silently, so `count` may be lower than the number you sent. A suggestion the model cannot rewrite comes back as a stub entry carrying `suggestion_id`, `chapter_number` and `error` instead of the text pair.

        Each entry costs a model call, so keep batches small.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the proofreading report.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 771
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProofreadingSuggestionSelection'
            example:
              suggestion_ids:
              - 4411
              - 4412
      responses:
        '200':
          description: The generated previews.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingPreviewResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          description: '`suggestion_ids` was missing or empty.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: No suggestions provided
                message: Please select at least one suggestion to preview
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The previews could not be generated. Also the answer for an unknown or other-account
            report id — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to generate previews
                message: No ProofreadingReport matches the given query.
  /proofreading-reports/{id}/apply/:
    post:
      operationId: applyProofreadingSuggestions
      tags:
      - Proofreading
      summary: Apply suggestions back into the chapters
      description: |-
        Rewrites the flagged passage of each selected suggestion with an AI-generated replacement and **saves the chapter**. Suggestions already applied are skipped, as are ids that belong to another report; a suggestion whose `location_text` can no longer be found in the chapter is skipped too and not counted. `applied_count` is therefore the authoritative number, and it can be lower than the number of ids you sent — reconcile against it rather than assuming success.

        This is destructive and not idempotent at the chapter level: re-sending the same ids is safe (they are already `is_applied`), but there is no undo. Preview first with `POST /proofreading-reports/{id}/preview/`.

        **Quota.** The `proofreading_edits` allowance is checked for the whole batch up front — an over-budget batch is rejected with **402** and nothing is written — and then incremented by `applied_count`, not by the number requested.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the proofreading report.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 771
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProofreadingSuggestionSelection'
            example:
              suggestion_ids:
              - 4411
              - 4412
              - 4415
      responses:
        '200':
          description: What was actually applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingApplyResponse'
              example:
                applied_count: 2
                updated_chapter_ids:
                - 9901
                - 9904
                message: 'Applied suggestions: 2'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          description: '`suggestion_ids` was missing, empty, or not an array.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Invalid data format
                message: suggestion_ids array required
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: The batch is larger than the `proofreading_edits` allowance left in this billing
            period. Nothing was written. This gate uses its own body, not the shared quota envelope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingEditQuotaError'
              example:
                error: Quota exceeded for edits
                limit: 50
                remaining: 2
                requested: 3
                message: You can apply 2 of 50 more edits this month.
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The batch could not be applied. Also the answer for an unknown or other-account report
            id — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to apply suggestions
                message: No ProofreadingReport matches the given query.
  /proofreading-suggestions/{id}/apply/:
    post:
      operationId: applyProofreadingSuggestion
      tags:
      - Proofreading
      summary: Generate one replacement and mark it applied
      description: |-
        Generates the AI replacement for a single suggestion, marks it `is_applied`, and hands the text back.

        **It does not edit the chapter.** Unlike `POST /proofreading-reports/{id}/apply/`, this endpoint only returns `replacement_text` — splicing it into the chapter is the caller's job (this is the call the web editor uses so the writer can see the change land in the editor before saving). The suggestion is flagged applied either way, so a caller that drops the response leaves the report claiming an edit that was never made.

        Takes no request body. A suggestion that is already applied answers **400**; it cannot be re-run. This path does not draw on the `proofreading_edits` allowance.

        **No 404 here.** The ownership lookup runs inside the view's own `try/except Exception`, which swallows Django's `Http404` before DRF can turn it into the standard envelope. An unknown id, or one belonging to another account, therefore answers **500** with a verbatim `{"error": …, "message": "No … matches the given query."}` body. Treat that message as "not found", not as an outage.
      security:
      - bearerAuth: []
      parameters:
      - name: id
        in: path
        required: true
        description: Numeric id of the suggestion to rewrite.
        schema:
          type: integer
          format: int64
          minimum: 1
        example: 4411
      responses:
        '200':
          description: The generated replacement text.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingSingleApplyResponse'
              example:
                replacement_text: Marta had already learned the lesson twice over.
                suggestion_id: 4411
                issue_type: repetition
                is_applied: true
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          description: The suggestion was applied by an earlier call.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Suggestion already applied
                message: This suggestion has already been applied to the chapter
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          description: The model returned no replacement, or the suggestion id is unknown or belongs to
            another account — see the note above.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProofreadingViewError'
              example:
                error: Failed to generate replacement
                message: AI failed to generate a replacement for this suggestion
  /stats/:
    get:
      operationId: getDashboardStats
      tags:
      - Utility
      summary: Get dashboard statistics
      description: |-
        Aggregate counters for the signed-in user plus their five most recently touched books.

        `generations_remaining` and `generations_limit` are a legacy display pair, not the billing quota — read `GET /auth/subscriptions/current/` for authoritative limits.

        **Path note:** this lives at `/stats/`, not under `/books/`.
      security:
      - bearerAuth: []
      responses:
        '200':
          description: Dashboard counters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DashboardStats'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /public-settings/:
    get:
      operationId: getPublicSettings
      tags:
      - Utility
      summary: Get public site settings
      description: Presentation switches the marketing site reads at boot — which hero animation to run
        in each theme, and its tuning parameters. Public and safe to cache; it exposes no account data.
      security: []
      responses:
        '200':
          description: Public settings.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicSettings'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chatbot/ask/:
    post:
      operationId: askChatbot
      tags:
      - Utility
      summary: Ask the support assistant
      description: |-
        Retrieval-augmented question answering over the KDPBot knowledge base — plans, quotas, the generation pipeline, exports and troubleshooting. No authentication required.

        Omit `session_id` on the first turn and reuse the returned id afterwards so the conversation stays threaded. If a human operator has taken over the session, `answer` is null and `human_connected` is true: the message was queued for a person, not answered by the model.

        For questions about the API itself, prefer the written docs at https://kdpbot.com/docs/api.md.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChatAskRequest'
      responses:
        '200':
          description: The assistant's answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChatAskResponse'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /chatbot/health/:
    get:
      operationId: getChatbotHealth
      tags:
      - Utility
      summary: Check support-assistant health
      description: Liveness probe reporting whether the retrieval index is loaded and the assistant can
        answer.
      security: []
      responses:
        '200':
          description: Health report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChatHealth'
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
        '500':
          $ref: '#/components/responses/InternalServerError'
  /auth/stripe/webhook/:
    post:
      operationId: handleStripeWebhook
      tags:
      - Utility
      summary: Receive a Stripe webhook
      description: |-
        Stripe's callback for subscription, add-on and credit events. Authenticated by the `Stripe-Signature` header against the endpoint's signing secret, **not** by a bearer token — an invalid signature returns 400.

        Documented for completeness only. This endpoint is for Stripe; do not call it.
      security: []
      parameters:
      - name: Stripe-Signature
        in: header
        required: true
        description: Stripe's HMAC signature over the raw request body.
        schema:
          type: string
        example: t=1756800000,v1=5257a869e7ec...
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: Raw Stripe Event object. The body must be forwarded byte-for-byte for signature
                verification to succeed.
              properties:
                id:
                  type: string
                  examples:
                  - evt_1P0abcXYZ
                type:
                  type: string
                  examples:
                  - checkout.session.completed
                data:
                  type: object
                  additionalProperties: true
        description: Raw Stripe event payload.
      responses:
        '200':
          description: Event accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StripeWebhookAck'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |-
        JWT access token issued by `POST /auth/login/`, `POST /auth/register/` or `POST /auth/google/`, sent as `Authorization: Bearer <access>`.

        Access tokens are valid for 24 hours. Renew with `POST /auth/token/refresh/`, which — because refresh rotation is enabled — returns a new refresh token too; store it and discard the previous one. Refresh tokens are valid for 30 days.

        Tokens are signed with HS256 and carry a `user_id` claim. Endpoints marked `security: []` need no credentials.
  headers:
    RateLimitLimit:
      description: Requests permitted in the current window for this credential.
      schema:
        type: integer
        minimum: 0
      example: 120
    RateLimitRemaining:
      description: Requests still available in the current window. Back off before it reaches zero.
      schema:
        type: integer
        minimum: 0
      example: 118
    RateLimitReset:
      description: Seconds until the current window resets and `RateLimit-Remaining` returns to `RateLimit-Limit`.
      schema:
        type: integer
        minimum: 0
      example: 47
    RetryAfter:
      description: Seconds to wait before retrying. Sent on 429 and on 503. Honour it exactly; retrying sooner extends
        the window.
      schema:
        type: integer
        minimum: 0
      example: 47
    XAPIVersion:
      description: API version that served this response.
      schema:
        type: string
        example: '1'
    XAPIVersionLatest:
      description: Newest available API version. If greater than X-API-Version, a newer surface exists.
      schema:
        type: string
        example: '1'
    Deprecation:
      description: HTTP-date at which this API version became deprecated. Absent unless deprecated.
      schema:
        type: string
        example: Wed, 01 Jan 2031 00:00:00 GMT
    Sunset:
      description: RFC 8594 HTTP-date at which this API version stops being served. Announced at least six months
        ahead.
      schema:
        type: string
        example: Thu, 01 Jul 2031 00:00:00 GMT
  responses:
    BadRequest:
      description: Malformed request — a required field is missing, a value is out of range, or the requested
        state transition is impossible.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: VALIDATION_ERROR
              message: The request body failed validation.
              status: 400
              documentation_url: https://kdpbot.com/docs/errors.md
              details:
                title:
                - This field is required.
    Unauthorized:
      description: No credentials, or the access token is missing, malformed or expired. Exchange the
        refresh token at `POST /auth/token/refresh/` and retry once.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: NOT_AUTHENTICATED
              message: Authentication credentials were not provided or are no longer valid.
              status: 401
              documentation_url: https://kdpbot.com/docs/errors.md
    PaymentRequired:
      description: Plan or quota gate. Emitted when the monthly book allowance is exhausted, the requested
        word count exceeds the plan ceiling, AI imagery is requested on the Free plan, or an export format
        the plan does not include is requested. This endpoint family still uses the legacy quota envelope
        rather than the uniform `Error` shape.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/QuotaError'
    Forbidden:
      description: Authenticated, but not permitted — for example an unverified e-mail address, a blocked
        fingerprint, or an admin-only route.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: PERMISSION_DENIED
              message: You do not have permission to perform this action.
              status: 403
              documentation_url: https://kdpbot.com/docs/errors.md
    NotFound:
      description: No such resource. Because every queryset is scoped to the caller, another account's
        id also lands here rather than on 403.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: NOT_FOUND
              message: The requested resource does not exist.
              status: 404
              documentation_url: https://kdpbot.com/docs/errors.md
    Conflict:
      description: 'The resource is in a state that rules the operation out — for example a checkout opened for a plan
        the account already holds. An already-running generation is **not** one of these: `POST /books/{id}/generate/`
        reports that with 400.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: CONFLICT
              message: That operation conflicts with the current state of the resource.
              status: 409
              documentation_url: https://kdpbot.com/docs/errors.md
    UnprocessableContent:
      description: Syntactically valid but semantically rejected — for example an outline whose chapter
        numbers do not line up with the book.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: UNPROCESSABLE_ENTITY
              message: The request could not be processed as submitted.
              status: 422
              documentation_url: https://kdpbot.com/docs/errors.md
    TooManyRequests:
      description: Rate limit exceeded. Wait for the number of seconds in `Retry-After` and retry with
        exponential backoff plus jitter. See https://kdpbot.com/docs/rate-limits.md.
      headers:
        RateLimit-Limit:
          $ref: '#/components/headers/RateLimitLimit'
        RateLimit-Remaining:
          $ref: '#/components/headers/RateLimitRemaining'
        RateLimit-Reset:
          $ref: '#/components/headers/RateLimitReset'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: RATE_LIMITED
              message: Too many requests. Retry after 47 seconds.
              status: 429
              details:
                retry_after: 47
              documentation_url: https://kdpbot.com/docs/errors.md
    InternalServerError:
      description: Unexpected server-side failure, including upstream model or storage errors. Safe to
        retry idempotent reads; retry writes only with backoff.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              code: INTERNAL_ERROR
              message: An unexpected error occurred. Please try again.
              status: 500
              documentation_url: https://kdpbot.com/docs/errors.md
  schemas:
    Error:
      type: object
      title: Error
      description: |-
        Uniform error envelope returned by every KDPBot API failure. The HTTP status line and `error.status` always agree. `error.code` is a stable, machine-readable SCREAMING_SNAKE_CASE identifier — branch on it rather than on `error.message`, which is human-facing prose and may be reworded or localised at any time.

        See https://kdpbot.com/docs/errors.md for the full code catalogue.
      required:
      - error
      additionalProperties: false
      properties:
        error:
          type: object
          required:
          - code
          - message
          - status
          additionalProperties: false
          properties:
            code:
              type: string
              pattern: ^[A-Z][A-Z0-9_]*$
              description: Machine-readable error identifier in SCREAMING_SNAKE_CASE.
              examples:
              - VALIDATION_ERROR
              - NOT_AUTHENTICATED
              - QUOTA_EXCEEDED
              - RATE_LIMITED
              - BOOK_NOT_FOUND
              - INTERNAL_ERROR
            message:
              type: string
              description: Human-readable explanation. Never parse this string.
              examples:
              - The request body failed validation.
            status:
              type: integer
              format: int32
              minimum: 400
              maximum: 599
              description: HTTP status code, repeated inside the envelope for clients that only log the
                body.
              examples:
              - 400
            details:
              type: object
              description: Optional structured context. For validation failures this is a map of field
                name to an array of messages; for quota failures it carries `quota_type`, `limit`, `used`
                and `remaining`.
              additionalProperties: true
              examples:
              - title:
                - This field is required.
            documentation_url:
              type: string
              format: uri
              description: Link to the error reference.
              default: https://kdpbot.com/docs/errors.md
              examples:
              - https://kdpbot.com/docs/errors.md
      examples:
      - error:
          code: VALIDATION_ERROR
          message: The request body failed validation.
          status: 400
          details:
            title:
            - This field is required.
          documentation_url: https://kdpbot.com/docs/errors.md
    QuotaError:
      type: object
      title: QuotaError
      description: Legacy payment/quota envelope still emitted on HTTP 402 by the quota gates (book quota,
        word-count ceiling, Free-plan AI-image block, export-format gate). It predates the uniform `Error`
        envelope; treat `error` here as the machine code.
      additionalProperties: true
      properties:
        error:
          type: string
          description: Machine-readable reason.
          examples:
          - quota_exceeded
          - free_plan_restriction
          - Word count exceeds plan limit
        message:
          type: string
          description: Human-readable explanation.
        details:
          type: object
          additionalProperties: true
          properties:
            quota_type:
              type: string
              examples:
              - books
              - ai_images
            limit:
              type: integer
            used:
              type: integer
            remaining:
              type: integer
        upgrade:
          type: object
          additionalProperties: false
          properties:
            message:
              type: string
            url:
              type: string
              examples:
              - /pricing
        upgrade_required:
          type: boolean
        max_allowed:
          type: integer
          description: Word-count ceiling of the current plan.
        requested:
          type: integer
          description: Word count the caller asked for.
        allowed_formats:
          type: array
          description: Export formats the current plan permits.
          items:
            type: string
            enum:
            - pdf
            - docx
            - epub
      examples:
      - error: quota_exceeded
        message: You have used all books included in your plan this month.
        details:
          quota_type: books
          limit: 3
          used: 3
          remaining: 0
        upgrade:
          message: Upgrade your plan for more books
          url: /pricing
    MessageResponse:
      type: object
      title: MessageResponse
      description: Minimal acknowledgement payload.
      additionalProperties: false
      properties:
        message:
          type: string
          examples:
          - Operation completed successfully
        status:
          type: string
          description: Optional resulting state.
          examples:
          - paused
        success:
          type: boolean
    TokenPair:
      type: object
      title: TokenPair
      description: JWT credentials issued by the login / register / Google endpoints. The access token
        lives 24 hours, the refresh token 30 days, and refresh tokens rotate on every use (`ROTATE_REFRESH_TOKENS=True`).
      required:
      - access
      - refresh
      additionalProperties: false
      properties:
        access:
          type: string
          description: 'Short-lived bearer token (24 h). Send as `Authorization: Bearer <access>`.'
          examples:
          - eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIn0.sig
        refresh:
          type: string
          description: Long-lived token (30 d) exchanged at `POST /auth/token/refresh/`.
    SubscriptionSummary:
      type:
      - object
      - 'null'
      title: SubscriptionSummary
      description: Compact subscription snapshot embedded in user payloads. Null when the account has
        no subscription row yet.
      additionalProperties: false
      properties:
        id:
          type:
          - integer
          - 'null'
          format: int64
        status:
          type: string
          enum:
          - active
          - canceled
          - past_due
          - incomplete
          - trialing
        plan:
          type: object
          additionalProperties: false
          properties:
            id:
              type: integer
              format: int64
            name:
              type: string
              examples:
              - Professional
            slug:
              type: string
              examples:
              - professional
            features:
              $ref: '#/components/schemas/PlanFeatures'
        current_period_end:
          type:
          - string
          - 'null'
          format: date-time
    User:
      type: object
      title: User
      description: Authenticated account as returned by the login, register and Google sign-in endpoints.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
          examples:
          - 512
        username:
          type: string
          examples:
          - igor_wise
        email:
          type: string
          format: email
          examples:
          - author@example.com
        first_name:
          type: string
          examples:
          - Igor
        last_name:
          type: string
          examples:
          - Wise
        email_verified:
          type: boolean
        avatar_url:
          type:
          - string
          - 'null'
          format: uri
        is_staff:
          type: boolean
        is_superuser:
          type: boolean
        subscription:
          $ref: '#/components/schemas/SubscriptionSummary'
    UserSummary:
      type: object
      title: UserSummary
      description: Owner stub embedded in book payloads.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
        email:
          type: string
          format: email
        first_name:
          type: string
        last_name:
          type: string
    AuthResponse:
      type: object
      title: AuthResponse
      description: 'Token-issuing response. NOTE the asymmetry with `POST /auth/token/refresh/`: here
        the credentials are nested under `tokens`, while the refresh endpoint returns them flat at the
        top level.'
      additionalProperties: false
      properties:
        user:
          $ref: '#/components/schemas/User'
        tokens:
          $ref: '#/components/schemas/TokenPair'
        message:
          type: string
        email_verification_required:
          type: boolean
          description: Present and true when the deployment requires e-mail confirmation; `tokens` is
            then omitted.
    RegisterRequest:
      type: object
      title: RegisterRequest
      description: New-account payload. `username` and `email` must both be unused.
      required:
      - username
      - email
      - password
      additionalProperties: false
      properties:
        username:
          type: string
          minLength: 1
          maxLength: 150
          examples:
          - igor_wise
        email:
          type: string
          format: email
          examples:
          - author@example.com
        password:
          type: string
          format: password
          minLength: 8
          description: Plain-text password; validated by Django's password validators.
        first_name:
          type: string
          default: ''
          maxLength: 150
        last_name:
          type: string
          default: ''
          maxLength: 150
        fingerprint:
          type: string
          description: Optional browser fingerprint used by the anti-abuse layer. Two accounts per fingerprint
            are allowed; further attempts are rate-limited.
    LoginRequest:
      type: object
      title: LoginRequest
      description: Supply `email` or `username`, plus `password`.
      required:
      - password
      additionalProperties: false
      anyOf:
      - required:
        - email
      - required:
        - username
      properties:
        email:
          type: string
          format: email
          examples:
          - author@example.com
        username:
          type: string
          examples:
          - igor_wise
        password:
          type: string
          format: password
          examples:
          - s3cret-passphrase
    RefreshRequest:
      type: object
      title: RefreshRequest
      description: The refresh half of a token pair, exchanged for a new access token.
      required:
      - refresh
      additionalProperties: false
      properties:
        refresh:
          type: string
          description: A valid, unexpired refresh token.
    RefreshResponse:
      type: object
      title: RefreshResponse
      description: Flat token payload from SimpleJWT's `TokenRefreshView`. Because rotation is enabled
        a fresh refresh token is returned too — store it and discard the old one.
      required:
      - access
      additionalProperties: false
      properties:
        access:
          type: string
          description: New 24-hour access token.
        refresh:
          type: string
          description: New 30-day refresh token (rotation is enabled).
    GoogleLoginRequest:
      type: object
      title: GoogleLoginRequest
      description: A Google OAuth2 access token to trade for KDPBot credentials.
      required:
      - access_token
      additionalProperties: false
      properties:
        access_token:
          type: string
          description: Google OAuth2 access token. The server verifies it against googleapis.com/oauth2/v3/userinfo.
    UserProfile:
      type: object
      title: UserProfile
      description: Full profile of the authenticated user.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
        email:
          type: string
          format: email
        first_name:
          type: string
        last_name:
          type: string
        email_verified:
          type: boolean
        is_google_connected:
          type: boolean
        avatar_url:
          type:
          - string
          - 'null'
          format: uri
        is_staff:
          type: boolean
        is_superuser:
          type: boolean
        subscription:
          $ref: '#/components/schemas/SubscriptionSummary'
        profile:
          type: object
          additionalProperties: false
          properties:
            admin_theme:
              type: string
              examples:
              - dark
    ProfileUpdateRequest:
      type: object
      title: ProfileUpdateRequest
      description: All fields optional; only the supplied ones are changed.
      additionalProperties: false
      minProperties: 1
      properties:
        first_name:
          type: string
          maxLength: 150
        last_name:
          type: string
          maxLength: 150
        email:
          type: string
          format: email
          description: Must be unique across accounts.
    ProfileUpdateResponse:
      type: object
      title: ProfileUpdateResponse
      description: 'Deliberately narrower than the GET representation: no `email_verified`, `is_staff`,
        `is_superuser` or `subscription`, plus a `message`.'
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        username:
          type: string
        email:
          type: string
          format: email
        first_name:
          type: string
        last_name:
          type: string
        is_google_connected:
          type: boolean
        avatar_url:
          type:
          - string
          - 'null'
          format: uri
        message:
          type: string
          examples:
          - Profile updated successfully
    ChangePasswordRequest:
      type: object
      title: ChangePasswordRequest
      description: Old and new password. The current password is verified before the change is applied.
      required:
      - current_password
      - new_password
      additionalProperties: false
      properties:
        current_password:
          type: string
          format: password
        new_password:
          type: string
          format: password
          minLength: 8
    ForgotPasswordRequest:
      type: object
      title: ForgotPasswordRequest
      description: The address to send a reset link to.
      required:
      - email
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
          description: Lower-cased and trimmed server-side.
    ResetPasswordRequest:
      type: object
      title: ResetPasswordRequest
      description: The replacement password, submitted with the `uid` and `token` from the reset link.
      required:
      - new_password
      additionalProperties: false
      properties:
        new_password:
          type: string
          format: password
          minLength: 8
    PlanFeatures:
      type: object
      title: PlanFeatures
      description: Free-form JSON limit/flag map stored on the plan. The keys below are the documented
        ones; unknown keys may appear and must be tolerated.
      additionalProperties: true
      properties:
        books_per_month:
          type: integer
          examples:
          - 10
        ai_images_per_month:
          type: integer
          examples:
          - 100
        ai_edits_per_month:
          type: integer
          examples:
          - 50
        export_formats:
          type: array
          items:
            type: string
            enum:
            - pdf
            - docx
            - epub
          examples:
          - - pdf
            - docx
            - epub
        watermark:
          type: boolean
          description: 'True on the Free plan: exports carry a watermark.'
        priority_generation:
          type: boolean
        max_word_count_per_book:
          type: integer
          examples:
          - 80000
        cover_templates:
          type: string
          examples:
          - all
    SubscriptionPlan:
      type: object
      title: SubscriptionPlan
      description: A purchasable subscription tier. Prices are DRF `DecimalField`s and therefore serialise
        as strings.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          examples:
          - Professional
        slug:
          type: string
          examples:
          - professional
        price_monthly:
          type: string
          pattern: ^\d+\.\d{2}$
          examples:
          - '29.00'
          description: Decimal serialised as a string (DRF COERCE_DECIMAL_TO_STRING).
        price_yearly:
          type: string
          pattern: ^\d+\.\d{2}$
          examples:
          - '290.00'
        features:
          $ref: '#/components/schemas/PlanFeatures'
        description:
          type: string
        is_active:
          type: boolean
        sort_order:
          type: integer
    UserSubscription:
      type: object
      title: UserSubscription
      description: The caller's subscription record.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        plan:
          $ref: '#/components/schemas/SubscriptionPlan'
        plan_name:
          type: string
          examples:
          - Professional
        status:
          type: string
          enum:
          - active
          - canceled
          - past_due
          - incomplete
          - trialing
        current_period_start:
          type:
          - string
          - 'null'
          format: date-time
        current_period_end:
          type:
          - string
          - 'null'
          format: date-time
        cancel_at_period_end:
          type: boolean
        is_paid:
          type: boolean
        is_free:
          type: boolean
        created_at:
          type: string
          format: date-time
    UsageQuota:
      type:
      - object
      - 'null'
      title: UsageQuota
      description: Consumption counters for one billing period. `period_start`/`period_end` are dates
        (YYYY-MM-DD), not timestamps.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        period_start:
          type: string
          format: date
          examples:
          - '2026-09-01'
        period_end:
          type: string
          format: date
          examples:
          - '2026-09-30'
        books_created:
          type: integer
          minimum: 0
        ai_images_used:
          type: integer
          minimum: 0
        ai_edits_used:
          type: integer
          minimum: 0
        autopilot_books_used:
          type: integer
          minimum: 0
        proofreading_checks_used:
          type: integer
          minimum: 0
        proofreading_edits_used:
          type: integer
          minimum: 0
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CurrentSubscription:
      type: object
      title: CurrentSubscription
      description: Subscription, current-period usage and the plan's raw limit map in one call.
      additionalProperties: false
      properties:
        subscription:
          $ref: '#/components/schemas/UserSubscription'
        usage:
          $ref: '#/components/schemas/UsageQuota'
        limits:
          $ref: '#/components/schemas/PlanFeatures'
    UsageHistory:
      type: object
      title: UsageHistory
      description: The live quota row plus up to six previous billing periods, newest first.
      additionalProperties: false
      properties:
        current:
          $ref: '#/components/schemas/UsageQuota'
        history:
          type: array
          maxItems: 6
          items:
            $ref: '#/components/schemas/UsageQuota'
          description: Up to the six most recent periods, newest first.
    BookCreationOption:
      type: object
      title: BookCreationOption
      description: One balance a new book could be charged to, with the ceiling and price attached to it.
      additionalProperties: true
      properties:
        source:
          type: string
          enum:
          - subscription
          - credits
          - purchase_credits
          description: Which balance this option spends. `purchase_credits` is an offer to buy, not an existing balance.
        label:
          type: string
          description: Short human label, e.g. "Use Subscription".
          examples:
          - Use Subscription
        description:
          type: string
          description: Human detail carrying the actual remaining allowance, e.g. "3 book(s) remaining this month".
          examples:
          - 3 book(s) remaining this month
        price:
          type: number
          minimum: 0
          description: Cost in US dollars. `0` for a balance that is already paid for.
        max_words:
          type: integer
          minimum: 0
          description: Word-count ceiling for a book created from this source.
        available:
          type: boolean
    BookCreationOptions:
      type: object
      title: BookCreationOptions
      description: 'Whether a new book can be started right now, and from which balance. The remaining allowance is
        not reported as flat counters: it lives inside `options[]`, one entry per balance the book could be charged
        to, each with its own `max_words`.'
      additionalProperties: false
      properties:
        can_create:
          type: boolean
          description: False when no option is available — creating a book now would fail with 402.
        options:
          type: array
          description: Every source the book could be charged to, in the order the UI should offer them. Empty when
            nothing is available.
          items:
            $ref: '#/components/schemas/BookCreationOption'
        suggested_source:
          type:
          - string
          - 'null'
          enum:
          - subscription
          - credits
          - null
          description: Which balance the UI should spend first.
        error_message:
          type:
          - string
          - 'null'
          description: Why a book cannot be created, when `can_create` is false. Null otherwise.
    SubscriptionCheckoutRequest:
      type: object
      title: SubscriptionCheckoutRequest
      description: Which plan to buy and on what cadence.
      required:
      - plan
      additionalProperties: false
      properties:
        plan:
          type: string
          description: Plan slug. The `free` slug is rejected with 400.
          examples:
          - professional
        billing_period:
          type: string
          enum:
          - monthly
          - yearly
          default: monthly
        apply_coupon:
          type: boolean
          default: false
          description: Apply the win-back reactivation coupon when eligible.
    SubscriptionCheckoutResponse:
      type: object
      title: SubscriptionCheckoutResponse
      description: 'Two shapes share this endpoint: a normal Stripe Checkout hand-off (`sessionId`), or
        an instant prorated upgrade performed server-side (`upgraded: true`).'
      additionalProperties: false
      properties:
        sessionId:
          type: string
          description: Stripe Checkout Session id — pass to `stripe.redirectToCheckout`.
          examples:
          - cs_live_a1B2c3D4
        upgraded:
          type: boolean
          description: True when the plan change was applied immediately with proration.
        plan_name:
          type: string
        redirect_url:
          type: string
          description: Where to send the browser after an instant upgrade.
        message:
          type: string
    PortalResponse:
      type: object
      title: PortalResponse
      description: A short-lived Stripe Customer Portal link.
      required:
      - url
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          description: Single-use Stripe Customer Portal URL.
    Payment:
      type: object
      title: Payment
      description: One invoice on the account, with a link to its Stripe-hosted PDF. `amount` is a decimal
        string.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        amount:
          type: string
          description: Decimal serialised as a string.
          examples:
          - '29.00'
        currency:
          type: string
          examples:
          - usd
        status:
          type: string
          examples:
          - paid
        description:
          type: string
        invoice_pdf_url:
          type: string
          format: uri
        created_at:
          type: string
          format: date-time
    PaymentHistory:
      type: object
      title: PaymentHistory
      description: Recent invoices for the account.
      additionalProperties: false
      properties:
        payments:
          type: array
          maxItems: 20
          items:
            $ref: '#/components/schemas/Payment'
          description: The 20 most recent payments, newest first.
    Addon:
      type: object
      title: Addon
      description: A purchasable capability sold on top of a plan (for example Autopilot).
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          examples:
          - Autopilot
        slug:
          type: string
          examples:
          - autopilot
        price_monthly:
          type: string
          examples:
          - '19.00'
        price_per_use:
          type: string
          examples:
          - '4.00'
        monthly_usage_limit:
          type: integer
        description:
          type: string
        is_active:
          type: boolean
    UserAddon:
      type: object
      title: UserAddon
      description: An add-on attached to the caller's account, with its usage counters for the current
        period.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        addon:
          $ref: '#/components/schemas/Addon'
        addon_name:
          type: string
        status:
          type: string
          examples:
          - active
        usage_count:
          type: integer
          minimum: 0
        period_start:
          type: string
          format: date
        period_end:
          type: string
          format: date
        has_remaining_usage:
          type: boolean
        created_at:
          type: string
          format: date-time
    AddonCheckoutRequest:
      type: object
      title: AddonCheckoutRequest
      description: Which add-on to buy and whether to bill it monthly or per use.
      required:
      - addon
      additionalProperties: false
      properties:
        addon:
          type: string
          description: Add-on slug.
          examples:
          - autopilot
        billing_type:
          type: string
          enum:
          - monthly
          - per_use
          default: monthly
    CheckoutSession:
      type: object
      title: CheckoutSession
      description: Stripe Checkout hand-off.
      additionalProperties: false
      properties:
        sessionId:
          type: string
          examples:
          - cs_live_a1B2c3D4
        url:
          type: string
          format: uri
          description: Direct Stripe Checkout URL (returned by the credit endpoints).
    CreditPackage:
      type: object
      title: CreditPackage
      description: A pay-as-you-go bundle of book credits. Dollar fields are floats derived from the cent
        fields.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string
          examples:
          - Starter Pack
        slug:
          type: string
          examples:
          - starter-5
        credits:
          type: integer
          minimum: 1
          description: Books this package unlocks.
        price:
          type: number
          format: double
          description: '`price_cents / 100`.'
          examples:
          - 39.0
        price_cents:
          type: integer
          minimum: 0
          examples:
          - 3900
        price_per_book:
          type: number
          format: double
          examples:
          - 7.8
        price_per_book_cents:
          type: integer
          minimum: 0
          examples:
          - 780
        savings_percent:
          type: integer
          minimum: 0
          maximum: 100
        words_per_book:
          type: integer
        is_popular:
          type: boolean
        is_active:
          type: boolean
        display_order:
          type: integer
    CreditBalance:
      type: object
      title: CreditBalance
      description: Credit wallet for the caller. The row is created on first read, so this never 404s.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        balance:
          type: integer
          minimum: 0
          description: Credits available to spend.
        total_purchased:
          type: integer
          minimum: 0
        total_used:
          type: integer
          minimum: 0
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    CreditTransaction:
      type: object
      title: CreditTransaction
      description: One ledger entry. `amount` is a signed delta and `balance_after` is the wallet total
        once it was applied.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        transaction_type:
          type: string
          enum:
          - purchase
          - usage
          - refund
          - bonus
        amount:
          type: integer
          description: Signed credit delta.
        balance_after:
          type: integer
          minimum: 0
        package_name:
          type:
          - string
          - 'null'
        book_id:
          type:
          - integer
          - 'null'
          format: int64
        book_title:
          type:
          - string
          - 'null'
        description:
          type: string
        created_at:
          type: string
          format: date-time
    CreditCheckoutRequest:
      type: object
      title: CreditCheckoutRequest
      description: Which credit package to buy, with optional redirect overrides.
      required:
      - package_slug
      additionalProperties: false
      properties:
        package_slug:
          type: string
          pattern: ^[-a-zA-Z0-9_]+$
          examples:
          - starter-5
        success_url:
          type: string
          format: uri
          description: Override the post-payment redirect.
        cancel_url:
          type: string
          format: uri
          description: Override the cancellation redirect.
    GuestCreditCheckoutRequest:
      type: object
      title: GuestCreditCheckoutRequest
      description: Anonymous purchase. The buyer's e-mail is collected by Stripe Checkout; the webhook
        then provisions the account and mails a magic link.
      required:
      - package_slug
      additionalProperties: false
      properties:
        package_slug:
          type: string
          pattern: ^[-a-zA-Z0-9_]+$
          examples:
          - starter-5
        success_url:
          type: string
          format: uri
        cancel_url:
          type: string
          format: uri
    BookImage:
      type: object
      title: BookImage
      description: Illustration attached to the book, flattened across its chapters.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        url:
          type: string
          format: uri
        image_url:
          type: string
          format: uri
          description: Same value as `url`.
        chapter_number:
          type: integer
        type:
          type: string
          examples:
          - chapter
        prompt:
          type: string
    GenerationProgress:
      type:
      - object
      - 'null'
      title: GenerationProgress
      description: Live progress record for an in-flight or finished generation run.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        book:
          type: integer
          format: int64
          description: Book id.
        total_chapters:
          type: integer
          minimum: 0
        completed_chapters:
          type: integer
          minimum: 0
        current_chapter:
          type: integer
          minimum: 0
        status_message:
          type: string
          examples:
          - Writing chapter 4 of 12
        percentage:
          type: integer
          minimum: 0
          maximum: 100
        generation_status:
          type: string
          enum:
          - running
          - paused
          - stopped
          - completed
          - error
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        generation_started_at:
          type:
          - string
          - 'null'
          format: date-time
        generation_completed_at:
          type:
          - string
          - 'null'
          format: date-time
        generation_time_seconds:
          type:
          - integer
          - 'null'
        generation_time_formatted:
          type:
          - string
          - 'null'
          examples:
          - 1h 2m 3s
    Chapter:
      type: object
      title: Chapter
      description: One chapter of a book. `actual_word_count` / `word_count` / `ai_summary` are read-only
        — the server recomputes the word count from `content` on every save.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        book:
          type: integer
          format: int64
          description: Owning book id (writable foreign key).
        chapter_number:
          type: integer
          minimum: 1
        title:
          type: string
          maxLength: 255
        title_options:
          type: array
          items:
            type: string
          description: Alternative AI-suggested titles.
        summary:
          type: string
          description: Outline-level synopsis used as the writing brief.
        content:
          type: string
          description: Full chapter text (HTML from the editor).
        ai_summary:
          type: string
          readOnly: true
        target_word_count:
          type: integer
          minimum: 0
        actual_word_count:
          type: integer
          minimum: 0
          readOnly: true
        word_count:
          type: integer
          minimum: 0
          readOnly: true
          description: Alias of `actual_word_count`.
        is_generated:
          type: boolean
        created_at:
          type: string
          format: date-time
          readOnly: true
        updated_at:
          type: string
          format: date-time
          readOnly: true
    ChapterWriteRequest:
      type: object
      title: ChapterWriteRequest
      description: Writable chapter fields. Anything sent for `actual_word_count`, `word_count` or `ai_summary`
        is ignored.
      additionalProperties: false
      properties:
        book:
          type: integer
          format: int64
        chapter_number:
          type: integer
          minimum: 1
        title:
          type: string
          maxLength: 255
        title_options:
          type: array
          items:
            type: string
        summary:
          type: string
        content:
          type: string
        target_word_count:
          type: integer
          minimum: 0
        is_generated:
          type: boolean
    Book:
      type: object
      title: Book
      description: Full book representation, including every chapter with its text, the generation progress
        record and derived cover/image fields.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        user:
          $ref: '#/components/schemas/UserSummary'
        title:
          type: string
          maxLength: 255
        subtitle:
          type: string
          maxLength: 255
        genre:
          type: string
          enum:
          - fiction
          - non-fiction
          - mystery
          - romance
          - sci-fi
          - fantasy
          - biography
          - self-help
          - business
          - other
        target_audience:
          type: string
        description:
          type: string
        chapter_count:
          type: integer
          minimum: 1
        target_word_count:
          type: integer
          minimum: 0
        word_count:
          type: integer
          minimum: 0
          readOnly: true
          description: Sum of the chapters' actual word counts.
        word_count_target:
          type: integer
          readOnly: true
          description: Read-only alias of `target_word_count`.
        trim_size:
          type: string
          enum:
          - 5x8
          - 5.25x8
          - 5.5x8.5
          - 6x9
          - 6.14x9.21
          - 6.69x9.61
          - 7x10
          - 7.5x9.25
          - 8x10
          - 8.25x6
          - 8.25x8.25
          - 8.5x11
          default: 6x9
        status:
          type: string
          enum:
          - draft
          - outline
          - generating
          - completed
          - published
        cover_image:
          type:
          - string
          - 'null'
          format: uri
        cover_url:
          type:
          - string
          - 'null'
          format: uri
          description: Front layer of the primary saved cover, falling back to `cover_image`.
        cover_image_url:
          type:
          - string
          - 'null'
          format: uri
          description: Same value as `cover_url`.
        writing_style:
          type: string
        tone:
          type: string
        content_language:
          type: string
          enum:
          - en
          - es
          - de
          - fr
          - it
          - pt
          - ru
          - uk
          - pl
          - zh
          - ja
          - ko
          - ar
          - hi
          - nl
          - sv
          - tr
          - id
          default: en
        is_autopilot:
          type: boolean
        autopilot_id:
          type:
          - integer
          - 'null'
          format: int64
        chapters:
          type: array
          items:
            $ref: '#/components/schemas/Chapter'
        progress:
          $ref: '#/components/schemas/GenerationProgress'
        images:
          type: array
          items:
            $ref: '#/components/schemas/BookImage'
        sub_genres:
          type: array
          items:
            type: string
          description: Reserved; currently always empty.
        created_at:
          type: string
          format: date-time
          readOnly: true
        updated_at:
          type: string
          format: date-time
          readOnly: true
        is_exported:
          type: boolean
        exported_at:
          type:
          - string
          - 'null'
          format: date-time
        export_formats:
          type: array
          items:
            type: string
            enum:
            - pdf
            - docx
            - epub
        editor_used:
          type: boolean
        editor_total_time:
          type: integer
          description: Cumulative seconds spent in the editor.
        editor_last_opened:
          type:
          - string
          - 'null'
          format: date-time
        editor_session_count:
          type: integer
          minimum: 0
    BookListItem:
      type: object
      title: BookListItem
      description: Lightweight book row used by the list endpoint — no chapter bodies, with database-annotated
        counts.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        user:
          $ref: '#/components/schemas/UserSummary'
        title:
          type: string
        subtitle:
          type: string
        genre:
          type: string
          enum:
          - fiction
          - non-fiction
          - mystery
          - romance
          - sci-fi
          - fantasy
          - biography
          - self-help
          - business
          - other
        target_audience:
          type: string
        status:
          type: string
          enum:
          - draft
          - outline
          - generating
          - completed
          - published
        chapter_count:
          type: integer
          description: Planned number of chapters.
        chapters_count:
          type: integer
          description: Chapters that actually exist.
        word_count:
          type: integer
          description: Annotated sum of the chapters' word counts.
        cover_image_url:
          type:
          - string
          - 'null'
          format: uri
        is_autopilot:
          type: boolean
        autopilot_id:
          type:
          - integer
          - 'null'
          format: int64
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    BookWriteRequest:
      type: object
      title: BookWriteRequest
      description: 'Writable book fields. Unknown keys are silently dropped rather than rejected. Two
        aliasing quirks worth knowing: camelCase aliases (`targetAudience`, `chapterCount`, `trimSize`,
        `contentLanguage`, `word_count_target`) are accepted, while `tone`, `writingStyle` and `pov` are
        remapped to internal fields that are not persisted — set `writing_style` instead if you want the
        style to stick.'
      additionalProperties: true
      properties:
        title:
          type: string
          minLength: 1
          maxLength: 255
          examples:
          - The Art of Habit Formation
        subtitle:
          type: string
          maxLength: 255
          examples:
          - A 90-Day Transformation Guide
        genre:
          type: string
          enum:
          - fiction
          - non-fiction
          - mystery
          - romance
          - sci-fi
          - fantasy
          - biography
          - self-help
          - business
          - other
          default: fiction
          description: Human-readable genre names are mapped onto these keys where possible ('Science
            Fiction' → `sci-fi`, 'Adventure' → `fantasy`, 'Horror'/'Thriller' → `mystery`).
        target_audience:
          type: string
          examples:
          - Busy professionals aged 25-45
        description:
          type: string
        chapter_count:
          type: integer
          minimum: 1
          default: 10
        target_word_count:
          type: integer
          minimum: 1
          default: 40000
          description: Total words for the book; each chapter receives `target_word_count // chapter_count`.
        trim_size:
          type: string
          enum:
          - 5x8
          - 5.25x8
          - 5.5x8.5
          - 6x9
          - 6.14x9.21
          - 6.69x9.61
          - 7x10
          - 7.5x9.25
          - 8x10
          - 8.25x6
          - 8.25x8.25
          - 8.5x11
          default: 6x9
        status:
          type: string
          enum:
          - draft
          - outline
          - generating
          - completed
          - published
          default: draft
        cover_image:
          type: string
          format: uri
        content_language:
          type: string
          enum:
          - en
          - es
          - de
          - fr
          - it
          - pt
          - ru
          - uk
          - pl
          - zh
          - ja
          - ko
          - ar
          - hi
          - nl
          - sv
          - tr
          - id
          default: en
        is_autopilot:
          type: boolean
          default: false
        writing_style:
          type: string
          description: Free-text style guide handed to the model.
    BookDropdownItem:
      type: object
      title: BookDropdownItem
      description: Id and title only — for selector widgets.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        title:
          type: string
    OutlineEntry:
      type: object
      title: OutlineEntry
      description: One chapter row of an outline.
      required:
      - chapter_number
      additionalProperties: false
      properties:
        chapter_number:
          type: integer
          minimum: 1
        title:
          type: string
        summary:
          type: string
        title_options:
          type: array
          items:
            type: string
    UpdateOutlineRequest:
      type: object
      title: UpdateOutlineRequest
      description: Edited outline rows, matched to existing chapters by `chapter_number`.
      required:
      - outline
      additionalProperties: false
      properties:
        outline:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/OutlineEntry'
          description: Edited outline rows. Entries whose `chapter_number` does not exist are skipped.
    UpdateOutlineResponse:
      type: object
      title: UpdateOutlineResponse
      description: How many chapters changed, plus the refreshed book.
      additionalProperties: false
      properties:
        success:
          type: boolean
        updated_count:
          type: integer
          minimum: 0
        book:
          $ref: '#/components/schemas/Book'
    RegenerateChaptersRequest:
      type: object
      title: RegenerateChaptersRequest
      description: Which outline chapters to rewrite, whether to cascade into later ones, and optional
        steering.
      required:
      - chapter_numbers
      additionalProperties: false
      properties:
        chapter_numbers:
          type: array
          minItems: 1
          items:
            type: integer
            minimum: 1
          examples:
          - - 3
        cascade:
          type: boolean
          default: false
          description: Also rewrite the chapters that follow, so the outline stays coherent.
        guidance:
          type: string
          description: Free-text steer for the rewrite.
          examples:
          - Make chapter 3 more action-packed
    RegenerateChaptersResponse:
      type: object
      title: RegenerateChaptersResponse
      description: The revised outline and which chapters were touched.
      additionalProperties: false
      properties:
        success:
          type: boolean
        outline:
          type: array
          items:
            $ref: '#/components/schemas/OutlineEntry'
        regenerated_chapters:
          type: array
          items:
            type: integer
        cascade:
          type: boolean
    GenerationStartResponse:
      type: object
      title: GenerationStartResponse
      description: Acknowledgement that a background generation run was queued.
      additionalProperties: false
      properties:
        message:
          type: string
          examples:
          - Generation started
        book_id:
          type: integer
          format: int64
        task_id:
          type:
          - string
          - 'null'
          description: Celery task id when the broker is reachable.
        mode:
          type: string
          enum:
          - celery
          - threading
          description: '`threading` means the broker was unavailable and the run fell back to an in-process
            thread.'
    GenerationStatus:
      allOf:
      - $ref: '#/components/schemas/GenerationProgress'
      - type: object
        additionalProperties: false
        properties:
          outline:
            type: array
            description: Per-chapter completion snapshot computed by the view.
            items:
              type: object
              additionalProperties: false
              properties:
                title:
                  type: string
                chapter_number:
                  type: integer
                word_count:
                  type: integer
                has_content:
                  type: boolean
          progress_percentage:
            type: integer
            minimum: 0
            maximum: 100
          current_step:
            type: string
            examples:
            - Generating chapter 4
      title: GenerationStatus
      description: The progress record plus three view-computed fields. Poll this every few seconds while
        `generation_status` is `running`.
    ExportRequest:
      type: object
      title: ExportRequest
      description: Optional rendering options. Send `{}` (or no body) for the defaults. `settings.add_watermark`
        is set by the server and cannot be overridden.
      additionalProperties: false
      properties:
        settings:
          type: object
          description: Free-form renderer options.
          additionalProperties: true
          properties:
            pageSize:
              type: string
              examples:
              - 6x9
            fontFamily:
              type: string
              examples:
              - Georgia
            fontSize:
              type: number
              examples:
              - 11
            includeImages:
              type: boolean
            includeTableOfContents:
              type: boolean
    GenerateCoverRequest:
      type: object
      title: GenerateCoverRequest
      description: Art direction for the two AI cover variants.
      additionalProperties: true
      properties:
        description:
          type: string
          description: What the artwork should depict.
        author_name:
          type: string
        show_title:
          type: boolean
          default: true
        show_subtitle:
          type: boolean
          default: true
    CoverVariant:
      type: object
      title: CoverVariant
      description: One generated cover image and the prompt that produced it.
      additionalProperties: true
      properties:
        image_url:
          type: string
          format: uri
          description: Public URL of the rendered cover image.
          examples:
          - https://storage.kdpbot.com/covers/1042-variant-1.png
        prompt:
          type: string
          description: Prompt handed to the image model for this variant.
    GenerateCoverResponse:
      type: object
      title: GenerateCoverResponse
      description: The two generated cover variants, each an object carrying its own `image_url`, plus the variant selected
        by default. There are no flat `cover_variant_1` / `cover_variant_2` string fields on this response — those are
        columns on the Book record, not keys of this body.
      additionalProperties: true
      properties:
        variant_1:
          anyOf:
          - $ref: '#/components/schemas/CoverVariant'
          - type: 'null'
          description: First variant, or null if the model returned fewer than one image.
        variant_2:
          anyOf:
          - $ref: '#/components/schemas/CoverVariant'
          - type: 'null'
          description: Second variant, or null if the model returned fewer than two images.
        selected:
          type: string
          enum:
          - variant_1
          - variant_2
          description: Which variant was stored as the book's cover. Always `variant_1` on generation; change it with
            `POST /books/{id}/select-cover/`.
    SelectCoverRequest:
      type: object
      title: SelectCoverRequest
      description: Which of the two generated variants becomes the book's cover.
      required:
      - selected
      additionalProperties: false
      properties:
        selected:
          type: string
          enum:
          - variant_1
          - variant_2
    SelectCoverResponse:
      type: object
      title: SelectCoverResponse
      description: Confirmation with the URL now serving as the book's cover.
      additionalProperties: false
      properties:
        message:
          type: string
        selected_cover:
          type: string
          format: uri
    ReorderChaptersRequest:
      type: object
      title: ReorderChaptersRequest
      description: The complete desired chapter ordering. Send every chapter, not just the moved ones.
      required:
      - chapter_orders
      additionalProperties: false
      properties:
        chapter_orders:
          type: array
          minItems: 1
          description: The complete desired ordering. Applied in one transaction.
          items:
            type: object
            required:
            - id
            - chapter_number
            additionalProperties: false
            properties:
              id:
                type: integer
                format: int64
              chapter_number:
                type: integer
                minimum: 1
    TrackEditorRequest:
      type: object
      title: TrackEditorRequest
      description: Editor telemetry ping.
      additionalProperties: false
      properties:
        action:
          type: string
          enum:
          - open
          - heartbeat
          - close
          default: heartbeat
        duration:
          type: integer
          minimum: 0
          default: 0
          description: Seconds elapsed since the previous ping; added to the book's cumulative editor
            time.
    BookPreviewData:
      type: object
      title: BookPreviewData
      description: Aggregated payload for the 3-D flip-book preview.
      additionalProperties: true
      properties:
        title:
          type: string
        subtitle:
          type: string
        trim_size:
          type: string
          enum:
          - 5x8
          - 5.25x8
          - 5.5x8.5
          - 6x9
          - 6.14x9.21
          - 6.69x9.61
          - 7x10
          - 7.5x9.25
          - 8x10
          - 8.25x6
          - 8.25x8.25
          - 8.5x11
        cover_url:
          type:
          - string
          - 'null'
          format: uri
        back_cover_url:
          type:
          - string
          - 'null'
          format: uri
        pages:
          type: array
          description: Rendered page fragments in reading order.
          items:
            type: object
            additionalProperties: true
            properties:
              page_number:
                type: integer
              chapter_number:
                type: integer
              content:
                type: string
    ChapterImage:
      type: object
      title: ChapterImage
      description: An illustration attached to a chapter, whether AI-generated or uploaded.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        image_url:
          type: string
          format: uri
        prompt:
          type: string
        filename:
          type: string
          description: Falls back to the prompt when the file had no name.
        position:
          type: integer
          description: Insertion point within the chapter body.
        created_at:
          type: string
          format: date-time
    GenerateChapterImageRequest:
      type: object
      title: GenerateChapterImageRequest
      description: Optional art direction for an AI illustration.
      additionalProperties: true
      properties:
        prompt:
          type: string
          description: Art direction. Omit to let the server derive one from the chapter text.
        position:
          type: integer
          minimum: 0
          description: Where to insert the image in the chapter.
    GenerateChapterImageResponse:
      type: object
      title: GenerateChapterImageResponse
      description: The illustration that was created and attached.
      additionalProperties: true
      properties:
        id:
          type: integer
          format: int64
        image_url:
          type: string
          format: uri
        prompt:
          type: string
    UploadChapterImageRequest:
      type: object
      title: UploadChapterImageRequest
      description: Multipart upload of a user-supplied illustration (JPEG, PNG, GIF or WEBP, max 5 MB).
      required:
      - image
      additionalProperties: false
      properties:
        image:
          type: string
          format: binary
          description: JPEG, PNG, GIF or WEBP, at most 5 MB.
        position:
          type: integer
          minimum: 0
    ProofreadingReportStatus:
      type: string
      title: ProofreadingReportStatus
      description: Lifecycle of a proofreading pass. `queued` and `processing` mean the worker is still running;
        poll until `completed`. `failed` means the pass errored and the report holds no suggestions.
      enum:
      - queued
      - processing
      - completed
      - failed
    ProofreadingSeverity:
      type: string
      title: ProofreadingSeverity
      description: How badly the issue hurts the manuscript. These three values are the whole scale — there
        is no `minor`/`moderate`/`major` triple, whatever older internal notes say.
      enum:
      - low
      - medium
      - high
    ProofreadingIssueType:
      type: string
      title: ProofreadingIssueType
      description: |-
        What kind of problem was found. The eight values below are the complete set the model is allowed to emit.

        - `consistency` — a fact, name or detail contradicts an earlier one
        - `repetition` — the same idea or phrasing recurs
        - `logic` — an argument or causal chain does not hold
        - `style` — prose that clashes with the book's register
        - `character` — behaviour or voice out of keeping with the character
        - `plot` — a structural problem in the story
        - `timeline` — events that cannot sit in that order
        - `truncated` — text that stops mid-thought, usually a generation artefact
      enum:
      - consistency
      - repetition
      - logic
      - style
      - character
      - plot
      - timeline
      - truncated
    ProofreadingSeverityCounts:
      type: object
      title: ProofreadingSeverityCounts
      description: Suggestion counts per severity. All three keys are always present, zero included.
      additionalProperties: false
      properties:
        high:
          type: integer
          minimum: 0
        medium:
          type: integer
          minimum: 0
        low:
          type: integer
          minimum: 0
    ProofreadingFictionAnalysis:
      type: object
      title: ProofreadingFictionAnalysis
      description: Whole-book analysis written for fiction. Populated only when the book's genre is fiction;
        `{}` otherwise.
      additionalProperties: true
      properties:
        plot_consistency_score:
          type: number
          minimum: 0
          maximum: 100
          description: How well the plot holds together, 0–100.
        character_consistency_score:
          type: number
          minimum: 0
          maximum: 100
          description: How consistently the characters behave, 0–100.
        issues_found:
          type: integer
          minimum: 0
          description: Issue count the model reported for this pass.
        analysis_text:
          type: string
          description: Prose analysis, in English. Copied verbatim into the report's `summary`.
    ProofreadingNonfictionAnalysis:
      type: object
      title: ProofreadingNonfictionAnalysis
      description: Whole-book analysis written for non-fiction. Populated only when the book's genre is not
        fiction; `{}` otherwise.
      additionalProperties: true
      properties:
        repetition_score:
          type: number
          minimum: 0
          maximum: 100
          description: How free of repetition the text is, 0–100.
        logic_score:
          type: number
          minimum: 0
          maximum: 100
          description: How sound the argumentation is, 0–100.
        issues_found:
          type: integer
          minimum: 0
        analysis_text:
          type: string
          description: Prose analysis, in English. Copied verbatim into the report's `summary`.
    ProofreadingSuggestion:
      type: object
      title: ProofreadingSuggestion
      description: One flagged passage and the fix proposed for it. `location_text` is the exact snippet the
        apply endpoints search for in the chapter, so an edit that has since changed that passage makes the
        suggestion unappliable.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        report:
          type: integer
          format: int64
          description: Owning report id.
        chapter:
          type: integer
          format: int64
          description: Id of the chapter the passage lives in.
        chapter_title:
          type: string
          readOnly: true
        chapter_number:
          type: integer
          minimum: 1
          readOnly: true
        issue_type:
          $ref: '#/components/schemas/ProofreadingIssueType'
        severity:
          $ref: '#/components/schemas/ProofreadingSeverity'
        location_text:
          type: string
          description: The offending snippet, verbatim from the chapter — the find target for `apply/`.
        issue_description:
          type: string
          description: What is wrong with it.
        suggestion:
          type: string
          description: What to do about it. This is advice, not the replacement text; the replacement is generated
            on demand by `preview/` or `apply/`.
        is_applied:
          type: boolean
        applied_at:
          type:
          - string
          - 'null'
          format: date-time
          readOnly: true
        created_at:
          type: string
          format: date-time
          readOnly: true
    ProofreadingReport:
      type: object
      title: ProofreadingReport
      description: A full proofreading pass over one book, with its suggestions inline. Exactly one of `fiction_analysis`
        / `nonfiction_analysis` is filled in; the other is `{}`.
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        book:
          type: integer
          format: int64
        book_title:
          type: string
          readOnly: true
        user:
          type: integer
          format: int64
          description: Id of the account that requested the pass.
        status:
          $ref: '#/components/schemas/ProofreadingReportStatus'
        overall_score:
          type: number
          minimum: 0
          maximum: 100
          description: Overall quality score, 0–100. Averaged from the two scores in whichever analysis block
            applies. `0` until the pass completes.
        fiction_analysis:
          $ref: '#/components/schemas/ProofreadingFictionAnalysis'
        nonfiction_analysis:
          $ref: '#/components/schemas/ProofreadingNonfictionAnalysis'
        summary:
          type: string
          description: Copy of the analysis block's `analysis_text`. Empty until the pass completes.
        suggestions_count:
          type: integer
          minimum: 0
          readOnly: true
        applied_suggestions_count:
          type: integer
          minimum: 0
          readOnly: true
        duration_seconds:
          type:
          - integer
          - 'null'
          minimum: 0
          readOnly: true
          description: Wall-clock seconds from `started_at` to `completed_at`; `null` while either is unset.
        total_issues:
          type: integer
          minimum: 0
          readOnly: true
          description: Same count as `suggestions_count`, recomputed per request.
        issues_by_severity:
          $ref: '#/components/schemas/ProofreadingSeverityCounts'
          readOnly: true
        tokens_used:
          type: integer
          minimum: 0
          readOnly: true
          description: Input plus output tokens spent on the pass.
        estimated_cost_usd:
          type: string
          readOnly: true
          description: Estimated model spend in USD as a decimal string with four places.
          examples:
          - '0.0413'
        celery_task_id:
          type: string
          description: Celery task id of the worker run. Empty string when none was recorded.
        created_at:
          type: string
          format: date-time
          readOnly: true
        started_at:
          type:
          - string
          - 'null'
          format: date-time
          readOnly: true
        completed_at:
          type:
          - string
          - 'null'
          format: date-time
          readOnly: true
        suggestions:
          type: array
          readOnly: true
          description: Every suggestion in the report, unpaginated.
          items:
            $ref: '#/components/schemas/ProofreadingSuggestion'
    ProofreadingReportSummary:
      type: object
      title: ProofreadingReportSummary
      description: 'Report as it appears in a list: scores and counts without the `suggestions` array, the
        two analysis blocks, or the task and timing fields.'
      additionalProperties: false
      properties:
        id:
          type: integer
          format: int64
        book:
          type: integer
          format: int64
        book_title:
          type: string
          readOnly: true
        status:
          $ref: '#/components/schemas/ProofreadingReportStatus'
        overall_score:
          type: number
          minimum: 0
          maximum: 100
          description: Overall quality score, 0–100. Averaged from the two scores in whichever analysis block
            applies. `0` until the pass completes.
        summary:
          type: string
          description: Copy of the analysis block's `analysis_text`. Empty until the pass completes.
        suggestions_count:
          type: integer
          minimum: 0
          readOnly: true
        applied_suggestions_count:
          type: integer
          minimum: 0
          readOnly: true
        total_issues:
          type: integer
          minimum: 0
          readOnly: true
          description: Same count as `suggestions_count`, recomputed per request.
        issues_by_severity:
          $ref: '#/components/schemas/ProofreadingSeverityCounts'
          readOnly: true
        tokens_used:
          type: integer
          minimum: 0
          readOnly: true
          description: Input plus output tokens spent on the pass.
        estimated_cost_usd:
          type: string
          readOnly: true
          description: Estimated model spend in USD as a decimal string with four places.
          examples:
          - '0.0413'
        created_at:
          type: string
          format: date-time
          readOnly: true
        completed_at:
          type:
          - string
          - 'null'
          format: date-time
          readOnly: true
    ProofreadingLatestReport:
      type: object
      title: ProofreadingLatestReport
      description: The newest completed report for a book plus its suggestions, returned side by side. `report.suggestions`
        carries the same list, so either one will do.
      additionalProperties: false
      properties:
        report:
          $ref: '#/components/schemas/ProofreadingReport'
        suggestions:
          type: array
          items:
            $ref: '#/components/schemas/ProofreadingSuggestion'
    ProofreadingSuggestionPage:
      type: object
      title: ProofreadingSuggestionPage
      description: One page of suggestions. The only paginated response in this API — every other list endpoint
        returns a bare array.
      additionalProperties: false
      properties:
        count:
          type: integer
          minimum: 0
          description: Total matching suggestions across all pages, after filtering.
        next:
          type:
          - string
          - 'null'
          format: uri
          description: Absolute URL of the next page, or `null` on the last one.
        previous:
          type:
          - string
          - 'null'
          format: uri
          description: Absolute URL of the previous page, or `null` on the first one.
        results:
          type: array
          items:
            $ref: '#/components/schemas/ProofreadingSuggestion'
    ProofreadingRunQueued:
      type: object
      title: ProofreadingRunQueued
      description: A new proofreading pass was queued. Poll `report_id`.
      additionalProperties: false
      properties:
        task_id:
          type: string
          description: Celery task id of the queued run.
          examples:
          - 6f1c2b7a-2c1f-4a1e-9c34-7a2b1d0e5f88
        report_id:
          type: integer
          format: int64
        status:
          type: string
          enum:
          - queued
        message:
          type: string
    ProofreadingRunCached:
      type: object
      title: ProofreadingRunCached
      description: The manuscript is unchanged since the last pass, so the existing report was handed back
        instead of running a new one. No `task_id`, because nothing was queued.
      additionalProperties: false
      properties:
        report_id:
          type: integer
          format: int64
        status:
          type: string
          enum:
          - completed
        cached:
          type: boolean
          description: Always `true` on this response; the field is absent from the 202.
        message:
          type: string
    ProofreadingRunConflict:
      type: object
      title: ProofreadingRunConflict
      description: 409 body from `POST /books/{id}/proofreading/` when a pass is already in flight. Returned
        verbatim by the view, so `error` is a bare string rather than the standard `Error` envelope object.
      additionalProperties: false
      properties:
        error:
          type: string
          examples:
          - Check already running
        report_id:
          type: integer
          format: int64
          description: The report already running — poll this one.
        status:
          type: string
          enum:
          - queued
          - processing
        message:
          type: string
    ProofreadingViewError:
      type: object
      title: ProofreadingViewError
      description: 'Failure body returned verbatim by the proofreading views. It is **not** the shared `Error`
        envelope: `error` is a bare human-readable string, there is no `code` and no `documentation_url`,
        and the same shape covers 400, 404 and 500. Branch on the HTTP status here, not on a code.'
      additionalProperties: true
      properties:
        error:
          type: string
          description: Short reason.
          examples:
          - Failed to start proofreading
        message:
          type: string
          description: Detail. On a 500 raised by a missing row this is Django's own `No <Model> matches the
            given query.`
        current_status:
          type: string
          description: 'Only on the 400 from `POST /books/{id}/proofreading/`: the book''s actual status,
            which must be `completed` to proceed.'
          examples:
          - generating
    ProofreadingEditQuotaError:
      type: object
      title: ProofreadingEditQuotaError
      description: 402 body from `POST /proofreading-reports/{id}/apply/`. Its own shape — the counters sit
        at the top level, not under `details` as in the shared `QuotaError`.
      additionalProperties: true
      properties:
        error:
          type: string
          examples:
          - Quota exceeded for edits
        limit:
          type: integer
          description: Applied-edit allowance for the billing period.
        remaining:
          type: integer
          description: Edits still available.
        requested:
          type: integer
          description: How many ids the rejected call carried.
        message:
          type: string
    ProofreadingSuggestionSelection:
      type: object
      title: ProofreadingSuggestionSelection
      description: The suggestions to preview or apply. Ids that do not belong to the report in the path are
        ignored without error.
      additionalProperties: false
      required:
      - suggestion_ids
      properties:
        suggestion_ids:
          type: array
          description: Ids of suggestions from this report. Must be a non-empty array.
          minItems: 1
          items:
            type: integer
            format: int64
          examples:
          - - 4411
            - 4412
    ProofreadingApplyResponse:
      type: object
      title: ProofreadingApplyResponse
      description: What the batch actually changed. `applied_count` counts only the suggestions whose text
        was found and rewritten — skipped and already-applied ones are not counted, and no error is raised
        for them.
      additionalProperties: false
      properties:
        applied_count:
          type: integer
          minimum: 0
        updated_chapter_ids:
          type: array
          description: Ids of the chapters that were saved. De-duplicated, order not meaningful.
          items:
            type: integer
            format: int64
        message:
          type: string
          examples:
          - 'Applied suggestions: 2'
    ProofreadingPreviewItem:
      type: object
      title: ProofreadingPreviewItem
      description: One proposed rewrite, or — when the model produced nothing — a stub carrying `suggestion_id`,
        `chapter_number` and `error` and none of the other fields.
      additionalProperties: true
      properties:
        suggestion_id:
          type: integer
          format: int64
        chapter_number:
          type: integer
          minimum: 1
        chapter_title:
          type: string
        issue_type:
          $ref: '#/components/schemas/ProofreadingIssueType'
        severity:
          $ref: '#/components/schemas/ProofreadingSeverity'
        issue_description:
          type: string
        original_text:
          type: string
          description: The suggestion's `location_text`, whitespace-trimmed.
        replacement_text:
          type: string
          description: What `apply/` would write in its place.
        is_already_applied:
          type: boolean
          description: True when this suggestion has already been applied to the chapter.
        error:
          type: string
          description: Present only on a stub entry.
          examples:
          - Failed to generate AI replacement
    ProofreadingPreviewResponse:
      type: object
      title: ProofreadingPreviewResponse
      description: Proposed rewrites for the selected suggestions. Nothing has been written.
      additionalProperties: false
      properties:
        previews:
          type: array
          items:
            $ref: '#/components/schemas/ProofreadingPreviewItem'
        count:
          type: integer
          minimum: 0
          description: Length of `previews`. Lower than the number of ids sent when some were skipped.
    ProofreadingSingleApplyResponse:
      type: object
      title: ProofreadingSingleApplyResponse
      description: The replacement text for one suggestion. The suggestion is marked applied, but the chapter
        is **not** written — the caller has to splice `replacement_text` in itself.
      additionalProperties: false
      properties:
        replacement_text:
          type: string
          description: Text to substitute for the suggestion's `location_text`.
        suggestion_id:
          type: integer
          format: int64
        issue_type:
          $ref: '#/components/schemas/ProofreadingIssueType'
        is_applied:
          type: boolean
          description: Always `true` on this response.
    DashboardStats:
      type: object
      title: DashboardStats
      description: Counters for the signed-in user's dashboard.
      additionalProperties: false
      properties:
        total_books:
          type: integer
          minimum: 0
        completed_books:
          type: integer
          minimum: 0
        in_progress:
          type: integer
          minimum: 0
        draft_books:
          type: integer
          minimum: 0
        total_words:
          type: integer
          minimum: 0
        generations_remaining:
          type: integer
          description: Legacy display counter, not the billing quota — read `/auth/subscriptions/current/`
            for real limits.
        generations_limit:
          type: integer
          description: Legacy display ceiling.
        recent_activity:
          type: array
          maxItems: 5
          items:
            type: object
            additionalProperties: false
            properties:
              id:
                type: integer
                format: int64
              title:
                type: string
              status:
                type: string
                enum:
                - draft
                - outline
                - generating
                - completed
                - published
              updated_at:
                type: string
                format: date-time
    PublicSettings:
      type: object
      title: PublicSettings
      description: Landing-page presentation switches. Safe to fetch anonymously and to cache.
      additionalProperties: false
      properties:
        enable_hero_animations:
          type: boolean
        enable_hero_animation_light:
          type: boolean
        enable_hero_animation_dark:
          type: boolean
        hero_animation_light:
          type: string
          enum:
          - hyperspeed
          - letter_glitch
        hero_animation_dark:
          type: string
          enum:
          - hyperspeed
          - letter_glitch
        hyperspeed_settings:
          type: object
          additionalProperties: true
          description: Free-form tuning blob for the hyperspeed effect.
        letter_glitch_settings:
          type: object
          additionalProperties: true
          description: Free-form tuning blob for the glitch effect.
    ChatAskRequest:
      type: object
      title: ChatAskRequest
      description: Ask the public support assistant. Provide `message`, an `image`, or both.
      additionalProperties: false
      properties:
        message:
          type: string
          maxLength: 2000
          examples:
          - How many books does the Hobby plan include?
        chat_history:
          type: array
          description: Prior turns as `[question, answer]` pairs.
          items:
            type: array
            minItems: 2
            maxItems: 2
            items:
              type: string
        language:
          type: string
          maxLength: 10
          default: en
          description: 'Reply language: en, ru, uk, pl, de, fr, es, it, pt or zh.'
        image:
          type: string
          description: Base64 data URL of a screenshot to reason about.
        session_id:
          type: string
          format: uuid
          description: Omit on the first turn; reuse the returned id afterwards.
        fingerprint_data:
          type: object
          additionalProperties: true
          description: Optional client context stored with the transcript.
    ChatAskResponse:
      type: object
      title: ChatAskResponse
      description: The assistant's reply, plus session state. `answer` is null when a human operator has
        taken the session over.
      additionalProperties: false
      properties:
        answer:
          type:
          - string
          - 'null'
          description: Null when a human operator has taken over the session; the message is queued for
            them instead.
        timestamp:
          type: string
          format: date-time
        sources_count:
          type: integer
          minimum: 0
          description: Knowledge-base passages used to ground the answer.
        session_id:
          type:
          - string
          - 'null'
          format: uuid
        human_connected:
          type: boolean
        message_saved:
          type: boolean
    ChatHealth:
      type: object
      title: ChatHealth
      description: Liveness probe for the retrieval-augmented support assistant.
      additionalProperties: true
      properties:
        status:
          type: string
          examples:
          - ok
        knowledge_base_loaded:
          type: boolean
    StripeWebhookAck:
      type: object
      title: StripeWebhookAck
      description: Acknowledgement returned to Stripe once an event has been processed.
      additionalProperties: false
      properties:
        status:
          type: string
          examples:
          - success
