{
  "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,\ncover art, and export to PDF, DOCX or EPUB. It is the same API that powers\n[kdpbot.com](https://kdpbot.com), and it is designed to be driven by autonomous agents as\nreadily as by a browser.\n\n## The pipeline\n\n1. `POST /books/` — create a draft with a title, genre, length and audience.\n2. `POST /books/{id}/generate-outline/` — the model proposes chapters; edit them with\n   `POST /books/{id}/update-outline/` or rework a few with `POST /books/{id}/regenerate-chapters/`.\n3. `POST /books/{id}/generate/` — queues the long-running write. Returns `202` immediately.\n4. `GET /books/{id}/generation-status/` — poll every few seconds until `generation_status` is\n   `completed`. Pause, resume and stop endpoints steer a run in flight.\n5. `POST /books/{id}/generate-cover/` then `POST /books/{id}/select-cover/` — cover art.\n6. `POST /books/{id}/export_pdf/` (or `export_docx`, `export_epub`) — file bytes, ready for KDP.\n\n## Conventions that will bite you if you skip them\n\n- **Trailing slashes are mandatory.** `/books` is not `/books/`; a slashless POST is redirected\n  and loses its body.\n- **The server URL already ends in `/api`.** Paths in this document are relative to it, so the\n  full URL for `/books/` is `https://bookgen-backend-5zuf.onrender.com/api/books/`.\n- **Almost no pagination.** List endpoints return a bare JSON array, never a `{count, next,\n  results}` envelope. The single exception is `GET /proofreading-reports/{id}/suggestions/`,\n  which is paged 20 at a time.\n- **Two export paths use underscores**: `export_pdf`, `export_docx`, `export_epub`, `preview_pdf`\n  and `my_addons`. Everything else uses hyphens.\n- **Exports return binary**, not JSON. Check the status code before parsing.\n- **Ownership is scoped by 404.** Another account's id looks like a missing resource, not a\n  forbidden one.\n- **Money is strings.** Plan and payment amounts are decimal strings; credit packages also expose\n  authoritative integer cent fields.\n\n## Authentication\n\nJWT bearer tokens. `POST /auth/login/` or `POST /auth/register/` returns a pair nested under\n`tokens`; send the access token as `Authorization: Bearer <access>`. Access tokens last 24 hours,\nrefresh tokens 30 days, and refresh rotates on every use — store the new refresh token each time.\nFull walkthrough: <https://kdpbot.com/docs/authentication.md>.\n\n## Errors and limits\n\nEvery failure returns the same envelope, with a stable SCREAMING_SNAKE `error.code` to branch on:\n<https://kdpbot.com/docs/errors.md>. Rate-limit state is advertised through the `RateLimit-Limit`,\n`RateLimit-Remaining` and `RateLimit-Reset` response headers, and a 429 carries `Retry-After`:\n<https://kdpbot.com/docs/rate-limits.md>.\n\n## More\n\n- Quickstart: <https://kdpbot.com/docs/quickstart.md>\n- Human-readable API reference: <https://kdpbot.com/docs/api.md>\n- Developer portal: <https://kdpbot.com/developers/>\n- Agent guidance: <https://kdpbot.com/llms.txt> and <https://kdpbot.com/agents.md>\n- This spec: <https://kdpbot.com/openapi.json> (JSON) and <https://kdpbot.com/openapi.yaml> (YAML)\n\nQuestions: support@kdpbot.com, [Telegram](https://t.me/blabla_ecommerce), or\n[WhatsApp](https://wa.me/380994019521).\n\n\n## Versioning\n\nThe 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/`.\n\nEvery 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.\n\nFull policy: https://kdpbot.com/docs/versioning.md\n",
    "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.\n\nIf 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.\n\nAn 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.\n\nThe 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/`.\n\nNote 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.\n\nUnlike 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.\n\nThe 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.\n\nReturns 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.\n\nCheck this before starting expensive work — comparing `usage.books_created` against `limits.books_per_month` avoids a 402 halfway through a generation run.\n\nReturns 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`.\n\nWhen 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.\n\nThe `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.\n\n**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.\n\nUnlike 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`.\n\n**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`).\n\nThe book allowance is *checked* here (402 when exhausted) but *consumed* only when a generation run completes, so creating and deleting drafts costs nothing.\n\nUnknown 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.\n\nTypical 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.\n\nThis call is synchronous and can take tens of seconds — set a generous client timeout. It takes no request body.\n\nDestructive 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.\n\nSupplying `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.\n\nPreconditions: the book must already have an outline (chapters), and no run may be active for it. Quota is checked here and consumed on completion.\n\n**Path note:** the route is `/books/{id}/generate/`. The `generate_content` path that older documentation advertises does not exist.\n\n`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).\n\n**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`.\n\n`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/`.\n\nTakes 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.\n\nTakes 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.\n\nTakes 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/`.\n\nCosts 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.\n\n**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.\n\n**Path note:** the segment uses an underscore (`export_pdf`), not a hyphen.\n\nA 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`.\n\n**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.\n\n**Path note:** the segment uses an underscore (`export_docx`), not a hyphen.\n\nA 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.\n\n**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.\n\n**Path note:** the segment uses an underscore (`export_epub`), not a hyphen.\n\nA 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.\n\n**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.\n\nBare 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.\n\n`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.\n\nSynchronous: 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.\n\nConsumes 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.\n\nAccepted 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.\n\nThe 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\": …}`.\n\n**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.\n\nProofreading checks are free and unmetered during the current beta — only *applying* suggestions draws on the `proofreading_edits` allowance.\n\n**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.\n\nReports 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.\n\n**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.\n\nEntries 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.\n\n**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`.\n\nExactly 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`.\n\nPoll this while `status` is `queued` or `processing`. `duration_seconds` is `null` until the pass finishes.\n\n**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.\n\n**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.\n\n**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/`.\n\nIds 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.\n\nEach entry costs a model call, so keep batches small.\n\n**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.\n\nThis 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/`.\n\n**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.\n\n**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.\n\n**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.\n\nTakes 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.\n\n**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.\n\n`generations_remaining` and `generations_limit` are a legacy display pair, not the billing quota — read `GET /auth/subscriptions/current/` for authoritative limits.\n\n**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.\n\nOmit `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.\n\nFor 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.\n\nDocumented 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>`.\n\nAccess 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.\n\nTokens 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.\n\nSee 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.\n\n- `consistency` — a fact, name or detail contradicts an earlier one\n- `repetition` — the same idea or phrasing recurs\n- `logic` — an argument or causal chain does not hold\n- `style` — prose that clashes with the book's register\n- `character` — behaviour or voice out of keeping with the character\n- `plot` — a structural problem in the story\n- `timeline` — events that cannot sit in that order\n- `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"
            ]
          }
        }
      }
    }
  }
}