{
  "openapi": "3.0.3",
  "info": {
    "title": "EveryPage API",
    "description": "Upload documents and get trackable share links programmatically. Authenticate with a personal API key (create one at https://everypage.co/account under API keys) or an OAuth 2.0 access token, sent as a bearer token. Every error under /api/v1 is an RFC 9457 problem document (application/problem+json) - branch on its `code`.\n\nVersioning: /api/v1 is the contract - changes inside a version are additive only, and anything scheduled to go away carries Deprecation (RFC 9745) and Sunset (RFC 8594) headers with at least six months' notice (https://everypage.co/docs/developers/versioning; no active deprecations). Rate limits: 120 requests per minute per bearer token, advertised on every response in RateLimit and RateLimit-Policy (IETF structured fields) plus X-RateLimit-*, with Retry-After on 429. OAuth 2.0 server metadata: https://everypage.co/.well-known/oauth-authorization-server.\n",
    "version": "1.1.0",
    "termsOfService": "https://everypage.co/terms",
    "x-deprecation-policy": {
      "url": "https://everypage.co/docs/developers/versioning",
      "minimumNoticeDays": 180,
      "signals": [
        "Deprecation",
        "Sunset",
        "Link rel=deprecation"
      ],
      "activeDeprecations": []
    },
    "contact": {
      "email": "support@everypage.co"
    }
  },
  "externalDocs": {
    "url": "https://everypage.co/developers",
    "description": "Developer guide"
  },
  "servers": [
    {
      "url": "https://everypage.co"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "Files"
    },
    {
      "name": "Variants"
    },
    {
      "name": "Events"
    },
    {
      "name": "Webhooks"
    },
    {
      "name": "Account"
    },
    {
      "name": "Embedding"
    },
    {
      "name": "OAuth"
    }
  ],
  "paths": {
    "/api/v1": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "API discovery document",
        "operationId": "getApiIndex",
        "description": "Zero-credential discovery document at the API root - pointers to this specification, the developer docs, the credential lanes (API keys, OAuth server and protected-resource metadata), the rate-limit convention, the error format and the versioning policy. The one read an agent can make before it has a key.",
        "security": [],
        "responses": {
          "200": {
            "description": "The discovery document.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiIndex"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files": {
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Upload a document",
        "operationId": "uploadFile",
        "description": "Upload a document as multipart form-data (field `file`) and get a trackable share link. Returns the new file's UUID, short ID and ready-to-send `shareUrl` (`https://everypage.co/{shortId}`; the UUID form works too). PDFs are stored as sent; Word (.docx) and PowerPoint (.pptx/.ppt) files are converted to PDF server-side (legacy .doc is rejected with 400). Scope `files:write`; uses one of your plan's file slots - 3 Free, 100 Basic, unlimited Pro - and is capped at your plan's size. The file starts with no expiry and no gates.",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "A PDF (application/pdf). Word (.docx, up to 25MB) and PowerPoint (.pptx/.ppt, up to 50MB) are converted to PDF server-side; the stored PDF must fit your plan's cap (20MB free / 200MB Basic / 2GB Pro)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Uploaded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Your plan's file limit is reached (3 Free / 100 Basic) - delete a file or upgrade, then retry (code `forbidden`; limit-reached 403s are not `plan_required`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      },
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "List your files",
        "operationId": "listFiles",
        "description": "Every active file the caller owns, newest first, with share identifiers (`uuid`, `shortId`, `slug` when set) and current settings. Trashed files are excluded, and `revisions` is never included in the list (fetch one file for its replacement history). Scope `files:read`. Unpaginated.",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's files, newest first",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/File"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/import": {
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Import a PDF from an export URL",
        "operationId": "importFile",
        "description": "Create a file by server-side download of an allowlisted HTTPS export URL (e.g. a Canva design export). PDF only, redirect-free, capped at your plan's size; uses a file slot (403 when full). Returns `uuid`, `shortId`, `shareUrl` and an optional advisory `palette`. Scope `files:write`.\n\nThe response may include `palette` - colour swatches extracted from the imported document during the import (see the Palette schema). The palette is advisory and best-effort: extraction reads the first page's embedded images with a short time budget, so vector-only documents (and any extraction failure or timeout) simply omit the field. A missing palette is normal, never an error, and never fails the import.\n\n**Canva design lane (republish-in-place):** sending a verified `canvaUserToken` AND `designToken` pair binds the import to the Canva design. When a live file of yours is already bound to that design, the import REPLACES its bytes in place - same UUID, short id, QR and readership history - and the response reports `republished: true` with the new `contentVersion`. Works on every plan (unlike the /content endpoint), and a republish consumes no file-limit slot; the owner can switch the lane off per file (`canvaUpdatesEnabled`) or sever it permanently (`detachCanvaDesign`) via the settings endpoint. A `designToken` WITHOUT a valid `canvaUserToken` is ignored (the import proceeds as a plain create), as is any token that fails verification - a bad optional token never fails a publish.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Plain HTTPS export URL on the import host allowlist."
                  },
                  "filename": {
                    "type": "string",
                    "description": "Display name for the new file. Defaults to design.pdf."
                  },
                  "canvaUserToken": {
                    "type": "string",
                    "description": "Canva Apps SDK user JWT (verified against Canva's JWKS). Required for the design lane; travels in the body because the Authorization header already carries your EveryPage credential."
                  },
                  "designToken": {
                    "type": "string",
                    "description": "Canva design token (contentMetadata.designToken) binding the publish to a design. Ignored unless canvaUserToken is present and verifies."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Imported (or republished in place - see `republished`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImportResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The URL's host is not an allowed import source, your plan's file limit is reached, or (design lane) updates from Canva are switched off for the bound file.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "409": {
            "description": "Design lane only - the document was changed by another connection mid-replace. Retry.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "description": "The export source could not be fetched or returned an error.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/files/claim": {
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Claim an anonymous file into your account",
        "operationId": "claimFile",
        "description": "Attaches the anonymous file a `claimToken` references (returned by anonymous uploads, Canva publishes, and the Canva design status endpoint) to the authenticated bearer user's account. The programmatic twin of the /api/claim browser link: use it when the claiming identity must be EXACTLY the connected account - e.g. the Canva panel claims into the account its OAuth grant belongs to in the same gesture as connecting. The file keeps its expiry and encryption key; claiming unlocks the owner surfaces (library, readership, settings). Idempotent for the same owner: re-claiming a file you already own returns 200. Scope `files:write`.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "claimToken"
                ],
                "properties": {
                  "claimToken": {
                    "type": "string",
                    "description": "The claim token bound to the file (30-day validity)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Claimed (or already yours)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ClaimResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Missing/invalid bearer credential, or an invalid or expired claim token.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "Your plan's file limit is reached - free a slot or upgrade, then claim again.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "The file is no longer available to claim (expired and swept).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "409": {
            "description": "The file has already been claimed by a different account.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/canva/import": {
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Anonymous Canva import (Canva app credential)",
        "operationId": "canvaImport",
        "description": "Used by the EveryPage Canva app: creates an anonymous, immediately-shareable file from a Canva export URL. The bearer credential is a Canva Apps SDK user JWT verified against Canva's JWKS - NOT an EveryPage API key or OAuth token - so this endpoint is not callable with the credentials the rest of this API uses. The response carries the share link and QR URL, the `claimToken` (attach the file to an account via /api/claim) and `manageToken` (configure it via the v1 settings endpoint while it remains anonymous), and may include `palette` with the same advisory, best-effort semantics as the owned import above.\n\n**Republish-in-place:** an optional `designToken` binds the publish to the exporting Canva design. When the same Canva user publishes the same design again while the earlier file is live and UNCLAIMED, the bytes are replaced in place - same link, same QR - and the response reports `republished: true` with the new `contentVersion` (a republish consumes no upload-budget slot, and re-bases the 365-day expiry unless the uploader chose one via the settings endpoint). A CLAIMED file refuses this lane with 403 - the owning account must republish through /api/v1/files/import instead. A `designToken` that is absent or fails verification degrades to a plain unbound import; it never fails the publish.\n",
        "security": [
          {
            "canvaJwt": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Plain HTTPS export URL on the import host allowlist."
                  },
                  "filename": {
                    "type": "string",
                    "description": "Display name for the new file. Defaults to canva-design.pdf."
                  },
                  "designToken": {
                    "type": "string",
                    "description": "Canva design token (contentMetadata.designToken) binding the publish to a design for republish-in-place. Optional; unverifiable tokens are ignored."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Imported (or republished in place - see `republished`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CanvaImportResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Missing or invalid Canva user token.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "403": {
            "description": "The URL's host is not an allowed import source; or the design's link belongs to an EveryPage account (republish it via the connected import); or the link is frozen pending abuse review.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "409": {
            "description": "The document was changed by another connection mid-replace. Retry.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "413": {
            "description": "The export is larger than the anonymous upload limit.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Per-Canva-user rate limit or upload budget exceeded.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "502": {
            "description": "The export source could not be fetched or returned an error.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/canva/design/status": {
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Canva design publish status (Canva app credential)",
        "operationId": "canvaDesignStatus",
        "description": "Used by the EveryPage Canva Design Editor panel: reports whether the calling Canva user has a live published link for a design, and its state. Auth is a Canva Apps SDK user JWT in the Authorization header (like /api/v1/canva/import - NOT an EveryPage credential) PLUS a verified `designToken` in the body; both are required, because the response is derived entirely from the design id and an unproven id would make this an enumeration oracle.\n\nFor a live UNCLAIMED link the response carries the link identity, view count, `contentVersion`, and a fresh `claimToken`. For a link CLAIMED by an EveryPage account it carries `claimed: true` and the link's share/QR URLs only - never a claim token, and the view count and uuid only when the optional `accessToken` proves the caller IS the owning account (`connectedAccountIsOwner`). `publishedByOther` is a boolean-only disclosure that a DIFFERENT Canva user (a design collaborator) already published this design - publishing anyway would mint a second link behind any QR they already printed.\n",
        "security": [
          {
            "canvaJwt": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "designToken"
                ],
                "properties": {
                  "designToken": {
                    "type": "string",
                    "description": "Canva design token (getDesignToken / contentMetadata.designToken). REQUIRED and hard-verified - unlike the import lane there is no unbound fallback to degrade to."
                  },
                  "accessToken": {
                    "type": "string",
                    "description": "Optional EveryPage bearer credential (API key or OAuth access token). In the body because the Authorization header carries the Canva JWT on this lane. When present, the response includes connectedAccountIsOwner."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The design's publish state for the calling Canva user",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CanvaDesignStatus"
                }
              }
            }
          },
          "400": {
            "description": "Missing design token or malformed body.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid Canva user token, an invalid design token, or an invalid `accessToken`.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "description": "Per-Canva-user or per-IP rate limit exceeded.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "503": {
            "description": "Design verification is temporarily unavailable (signing keys unreachable). Retry.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/files/{uuid}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "Get a file's details",
        "operationId": "getFile",
        "description": "The owner's detail view of one file - identifiers, settings, counters, `contentVersion` and the `revisions` replacement history (the only place it is populated). Scope `files:read`; UUID or short id. A file you do not own, and an unclaimed anonymous file, answer 404, never 403.",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "File details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/File"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "Files"
        ],
        "summary": "Delete a file (trash or purge)",
        "operationId": "deleteFile",
        "description": "Delete a file you own (scope `files:write`; UUID or short id). On Basic/Pro the file goes to the trash - restorable in the web app for 7 days (Basic) or 30 days (Pro), its short id and slug still reserved - and the response reports `trashed: true` with `trashedAt`/`purgeAt`. Free plans hard-delete immediately (`trashed: false`). `?purge=1` forces an immediate hard delete on any plan, from the trash or straight past it. Deleting an already-trashed file again is a 200 no-op that repeats its trash state. An active file owned by someone else answers 403; a trashed file owned by someone else, or an unknown id, answers 404.",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "purge",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Hard-delete immediately instead of trashing (Basic/Pro; Free always hard-deletes). Also purges a file that is already in the trash."
          }
        ],
        "responses": {
          "200": {
            "description": "Trashed, or hard-deleted (see `trashed`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeleteResult"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The file is active and belongs to another account (code `forbidden`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "description": "Unknown id, or a trashed file that is not yours (code `not_found`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/settings": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "put": {
        "tags": [
          "Files"
        ],
        "summary": "Update a file's settings",
        "operationId": "updateFileSettings",
        "description": "Update a file's sharing settings: viewer mode, password, downloads, expiry (`deleteAt` or `neverExpire`), view limit, email gate, vanity slug, watermark, page range, appearance. Omitted fields keep their value. Owner via `files:write`, or the file's manage token while anonymous. 403 above plan, 409 slug in use, 410 dead link.\n\nBesides an API key or OAuth token (files:write), this endpoint accepts a **manage token** as the bearer credential: anonymous uploads return a `manageToken` bound to the new file, valid for 30 days and only while the file remains unclaimed (a claimed file answers 404 to token callers). Manage-token callers are limited to the free plan's settings; plan-gated fields return 403. The `claimToken` from the same response is a different credential (it attaches the file to an account) and is never accepted here.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          },
          {
            "manageToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FileSettings"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Settings applied (empty body)."
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "410": {
            "$ref": "#/components/responses/Gone"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/content": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "Download the stored PDF (owner only)",
        "operationId": "getFileContent",
        "description": "Stream the file's stored bytes to its owner, verbatim: no view gates, no view units consumed, no readership entry, no watermark or page-range trim. Scope `files:read`; UUID or short id. Files you do not own, and unclaimed files, answer 404 (never 403); a link burned by its view limit answers 410. Served as `Content-Type: application/pdf` with `Content-Disposition: inline; filename=...`; Range requests are not supported. The response is binary - hand the URL to the user or save it; do not return the body to a model.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The stored PDF, verbatim",
            "headers": {
              "Content-Disposition": {
                "description": "`inline; filename=<originalName>`",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No such file, not owned by you, or still unclaimed.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "410": {
            "description": "The link was burned by its view limit; its content is no longer served (replace the content to revive it).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Replace the document behind a link (dynamic links)",
        "operationId": "replaceFileContent",
        "description": "Swap the document behind a live link. UUID, short id, slug, QR, settings and readership history survive; `contentVersion` increments and the old file is kept as a revision. Pro plan, owner only. Un-burns a view-limited link; 410 if expired. Multipart, same shape as the upload endpoint (PDF, or Word/PowerPoint converted server-side); `?clearAnchors=false` keeps page-anchored hotspots and notes despite the page mismatch (default clears them).\n\nAccepts the same manage-token bearer lane as the settings endpoint, but since manage tokens act at the free tier, content replacement always answers 403 to them - it remains effectively owner-only.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          },
          {
            "manageToken": []
          }
        ],
        "parameters": [
          {
            "name": "clearAnchors",
            "in": "query",
            "required": false,
            "schema": {
              "type": "boolean",
              "default": true
            },
            "description": "Send `false` (or `0`) to keep page-anchored hotspots and notes despite the page mismatch. Anything else - including omitting it - clears them; document-level notes always survive."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "A PDF (application/pdf). Word (.docx, up to 25MB) and PowerPoint (.pptx/.ppt, up to 50MB) are converted to PDF server-side; the stored PDF must fit your plan's cap (20MB free / 200MB Basic / 2GB Pro)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Content replaced",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReplaceResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not the owner, a manage-token caller, or a plan below Pro (codes `forbidden` / `plan_required`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The document was changed by another connection mid-replace (code `conflict`). Retry.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "410": {
            "description": "The link has expired; expired links refuse replacement content (code `gone`). Burned links are NOT refused - replacing the content is the documented un-burn path.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/ServiceUnavailable"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/variants": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Variants"
        ],
        "summary": "List a file's link variants",
        "operationId": "listVariants",
        "description": "Every variant of the file, newest first - revoked and redacted ones included, so they can be managed here. Pro plan required; scope `files:read`.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The file's variants",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "variants": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Variant"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Variants"
        ],
        "summary": "Create a link variant",
        "operationId": "createVariant",
        "description": "Mint a per-recipient child link for a file you own (Pro, `files:write`). The variant has its own 12-char short id and URL, an optional `label` (recipient name, GDPR-redactable) and `overrides` (`allowDownload`, `pageRange`). Views through it are attributed in readership and in `file.viewed`/`file.downloaded` webhook payloads (`variantUuid`/`variantLabel`). Up to 200 variants per file.\n\nPasswords and viewing gates are deliberately per-FILE, not per-variant: a viewer who passed the file's gate through one link has passed it for all of the file's links.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VariantCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Variant"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, label too long, or variant limit (200) reached.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/variants/{variantUuid}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        },
        {
          "name": "variantUuid",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          },
          "description": "The variant's UUID from the create/list response (never the variant's short id)."
        }
      ],
      "put": {
        "tags": [
          "Variants"
        ],
        "summary": "Update a link variant",
        "operationId": "updateVariant",
        "description": "Update the label and/or overrides, or revoke/un-revoke the variant. Omitted fields keep their current values; `label: \"\"` clears the label. An `overrides` object replaces the stored override set as a whole (see VariantUpdate) - include every override you want to keep. A revoked variant's link answers 404 to viewers (without revealing that a canonical link exists) until un-revoked. A redacted variant's label can never be set again (409).\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VariantUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated variant",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Variant"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "tags": [
          "Variants"
        ],
        "summary": "Delete or redact a link variant",
        "operationId": "deleteVariant",
        "description": "Without parameters, permanently deletes the variant (its sessions survive, unattributed). With `?redact=1`, performs a GDPR redaction instead: the recipient label - the only personal field - is erased from the variant, all analytics surfaces, and every stored or pending webhook payload (anything already delivered to your own endpoints is yours to erase), and the variant keeps working, so analytics stay consistent. Redaction is the compliance path for erasure requests (for example Shopify's `customers/redact`). Idempotent.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "redact",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Redact in place instead of deleting."
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted (or redacted)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/readership": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "A file's readership report",
        "operationId": "getFileReadership",
        "description": "The owner's readership dashboard data. The response shape follows the owner's plan (a `tier` field says which sections are present). On the Pro plan the response includes a `variants` array - per-link-variant readership: `{uuid, label, sessions, avgTimeMs, revoked, redacted}` per variant, label omitted after GDPR redaction. Scope `readership:read`.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "readership:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Readership report (plan-shaped; `variants` present on Pro when the file has link variants)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReadershipReport"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not the owner (code `forbidden`). Unlike most file routes this one does not hide foreign files behind 404.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/readership/pages": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "A file's per-page readership (Basic+)",
        "operationId": "getFilePageReadership",
        "description": "Page-level read-through for one file: per-page view counts, average dwell time, and back-turns (returns to a page - the re-read signal). Owner only; requires the Basic plan or higher. Scope `readership:read`.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "readership:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Per-page stats",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "totalPages": {
                      "type": "integer"
                    },
                    "pages": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "pageNumber": {
                            "type": "integer"
                          },
                          "views": {
                            "type": "integer"
                          },
                          "avgDurationMs": {
                            "type": "integer"
                          },
                          "backTurns": {
                            "type": "integer",
                            "description": "Views of this page that arrived via backward navigation."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not the owner, or the plan is below Basic.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/page-marks": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "A file's page marks (Pro)",
        "operationId": "getFilePageMarks",
        "description": "Every verdict readers have left on the document's pages - `pick`, `reject` or `maybe` - as per-page tallies, a per-viewer breakdown, and document totals. Owner only; requires Pro. Scope `readership:read`.\n\nThis is a pull, not a push: the `proofing.updated` webhook fires only once per viewer per document, on their first mark, and carries no verdicts. Read this endpoint when that event arrives.\n\n`identity` is the invitee's email address for readers who arrived through an email invite, and a pseudonymous `Anonymous reader <id>` label otherwise - treat the response as personal data.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "readership:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "csv"
              ]
            },
            "description": "Return `text/csv` (one row per mark) instead of JSON."
          }
        ],
        "responses": {
          "200": {
            "description": "Page marks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "pages": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "page": {
                            "type": "integer"
                          },
                          "pick": {
                            "type": "integer"
                          },
                          "reject": {
                            "type": "integer"
                          },
                          "maybe": {
                            "type": "integer"
                          }
                        }
                      }
                    },
                    "viewers": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "identity": {
                            "type": "string",
                            "description": "Invitee email address, or a pseudonymous reader label."
                          },
                          "marks": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "page": {
                                  "type": "integer"
                                },
                                "mark": {
                                  "type": "string",
                                  "enum": [
                                    "pick",
                                    "reject",
                                    "maybe"
                                  ]
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "totals": {
                      "type": "object",
                      "properties": {
                        "pick": {
                          "type": "integer"
                        },
                        "reject": {
                          "type": "integer"
                        },
                        "maybe": {
                          "type": "integer"
                        }
                      }
                    }
                  }
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not the owner, or the plan is below Pro.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/annotations": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "A file's reader annotations (Pro)",
        "operationId": "getFileAnnotations",
        "description": "Every annotation readers have drawn on the document, grouped by page, with the marked-up region and the reader's note. Owner only; requires Pro. Scope `readership:read`. Same `identity` semantics and the same pull-on-event pattern as the page-marks endpoint above.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "readership:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "csv"
              ]
            },
            "description": "Return `text/csv` (one row per annotation) instead of JSON."
          }
        ],
        "responses": {
          "200": {
            "description": "Annotations",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "pages": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "page": {
                            "type": "integer"
                          },
                          "annotations": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "uuid": {
                                  "type": "string"
                                },
                                "identity": {
                                  "type": "string",
                                  "description": "Invitee email address, or a pseudonymous reader label."
                                },
                                "rect": {
                                  "type": "object",
                                  "description": "The annotated region on the page."
                                },
                                "note": {
                                  "type": "string"
                                },
                                "createdAt": {
                                  "type": "string",
                                  "format": "date-time"
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              },
              "text/csv": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not the owner, or the plan is below Pro.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/assets": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "post": {
        "tags": [
          "Files"
        ],
        "summary": "Upload a branding asset (Basic+)",
        "operationId": "uploadFileAsset",
        "description": "Stores an encrypted auxiliary image for the file and returns its `assetId`, which `viewerSettings.logo.assetId` and `viewerSettings.background.assetId` then reference - the settings PUT rejects an assetId that does not belong to the same file, so upload before you configure. `logo` and `background` are singletons: a second upload of the same kind replaces and deletes the first. PNG/JPEG/WebP up to 5MB (`hotspot-audio` accepts MP3 up to 10MB); max 10 per file.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "parameters": [
          {
            "name": "kind",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "logo",
                "background",
                "hotspot-image",
                "hotspot-audio"
              ]
            },
            "description": "What the asset is for. `logo` and `background` are singletons (a new upload replaces the previous one); hotspot kinds accumulate up to the per-file cap."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary",
                    "description": "PNG, JPEG or WebP up to 5MB for `logo`, `background` and `hotspot-image`; MP3 up to 10MB for `hotspot-audio`."
                  }
                },
                "required": [
                  "file"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "assetId": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid `kind`, malformed multipart, missing `file`, or an unsupported media type for that kind.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Not the owner, a plan below Basic, or the per-file asset cap (10) reached.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "description": "The asset is over its cap (5MB images, 10MB audio) (code `payload_too_large`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/assets/{assetId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        },
        {
          "name": "assetId",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          },
          "description": "The assetId UUID returned by the asset upload."
        }
      ],
      "delete": {
        "tags": [
          "Files"
        ],
        "summary": "Delete a branding asset",
        "operationId": "deleteFileAsset",
        "description": "Remove an asset uploaded to this file (owner only, scope `files:write`; any plan). Viewer settings or hotspots still pointing at the `assetId` lose their image or audio. 404 when the asset does not belong to this file.",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted (empty body)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/hotspots": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "put": {
        "tags": [
          "Files"
        ],
        "summary": "Replace the file's hotspots (Pro)",
        "operationId": "replaceFileHotspots",
        "description": "Whole-set replace, not a merge - omitted hotspots are deleted. Owner only, Pro plan, scope `files:write`. Rects are normalised 0-1 against the page box. Max 300 per file, 30 per page. `page` payloads must target a page that exists, and `image`/`audio` payloads reference an assetId owned by the same file.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "hotspots": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "page": {
                          "type": "integer",
                          "minimum": 1
                        },
                        "x": {
                          "type": "number",
                          "minimum": 0,
                          "maximum": 1
                        },
                        "y": {
                          "type": "number",
                          "minimum": 0,
                          "maximum": 1
                        },
                        "w": {
                          "type": "number",
                          "minimum": 0,
                          "maximum": 1
                        },
                        "h": {
                          "type": "number",
                          "minimum": 0,
                          "maximum": 1
                        },
                        "kind": {
                          "type": "string",
                          "enum": [
                            "link",
                            "page",
                            "video",
                            "image",
                            "audio"
                          ]
                        },
                        "payload": {
                          "type": "object",
                          "additionalProperties": true
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored set, with server-assigned uuids",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "hotspots": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid hotspot set",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/files/{uuid}/qr-code": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "QR code for the share link",
        "operationId": "getFileQrCode",
        "description": "Bearer-authed twin of the public `/api/files/{uuid}/qr-code` endpoint, returning the same PNG. There is NO ownership check: any valid credential with `files:read` can fetch the QR of any live file id, because the image only encodes the public share URL (durable short-id form on the owner's custom domain when verified, never the slug, with `?via=qr` so scans show up in the readership source breakdown). Unknown, expired, trashed or burned ids answer 404. Cached for a day (`Cache-Control: public, max-age=86400`). When handing a QR URL to a third party (an email, a Zap output, a downstream app), use the public path - it needs no Authorization header. The response is binary - hand the URL to the user rather than returning the bytes to a model.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "files:read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "PNG QR code encoding the share URL",
            "content": {
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "description": "`public, max-age=86400`",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/files/{uuid}/qr-code": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "QR code for the share link (public)",
        "operationId": "getPublicQrCode",
        "description": "Unauthenticated QR endpoint (per-IP rate limited, 60/min). This is the URL integration outputs should embed - e.g. the Zapier `qr_url` output - so anything downstream can fetch the PNG without credentials. Same image and rules as the v1 twin: encodes the durable share URL with `?via=qr`; unknown, expired, trashed or burned ids answer a plain-text 404 (this path is outside the /api/v1 problem-document lane). Cached for a day. The response is binary - hand the URL to the user rather than returning the bytes to a model.\n",
        "security": [],
        "responses": {
          "200": {
            "description": "PNG QR code encoding the share URL",
            "content": {
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            },
            "headers": {
              "Cache-Control": {
                "description": "`public, max-age=86400`",
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFoundText"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitedPublic"
          }
        }
      }
    },
    "/api/files/{uuid}/qr-code.png": {
      "parameters": [
        {
          "$ref": "#/components/parameters/fileId"
        }
      ],
      "get": {
        "tags": [
          "Files"
        ],
        "summary": "QR code for the share link (public, .png alias)",
        "operationId": "getPublicQrCodePng",
        "description": "Byte-identical alias of `/api/files/{uuid}/qr-code` whose `.png` extension lets clients that sniff the URL - Notion \"Files & media\" columns and other image-only embeds - treat it as an image. Same public access, the same per-IP limit, the same day-long cache and the same plain-text 404. Prefer it wherever a URL is pasted somewhere that may inspect the extension. The response is binary - hand the URL to the user rather than returning the bytes to a model.\n",
        "security": [],
        "responses": {
          "200": {
            "description": "PNG QR code encoding the share URL",
            "headers": {
              "Cache-Control": {
                "description": "`public, max-age=86400`",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFoundText"
          },
          "429": {
            "$ref": "#/components/responses/RateLimitedPublic"
          }
        }
      }
    },
    "/api/v1/events": {
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "Recent events",
        "operationId": "listEvents",
        "description": "Poll recent activity across your files. One stream per call via `type`: `view` (default), `download`, or `gate` (Pro). Newest first without `since`; ascending after the cursor with `since=<last id>`. Cursors are per stream - never reuse a view id on download or gate (`view`/`download` ids are 32-bit; `gate` ids are 64-bit; there is no `type=all`). `file=` narrows to one file (UUID or short id; unknown = `[]`). Scope `readership:read`.\nNote: the `download` stream records every explicit download, including your own - unlike the `file.downloaded` webhook, which only fires for non-owner downloads.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "readership:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "type",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "view",
                "download",
                "gate"
              ],
              "default": "view"
            },
            "description": "Which event stream to read. Cursors are NOT portable across types."
          },
          {
            "name": "file",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Only this file's events (UUID or short id). Unknown or foreign identifiers return an empty list, not an error."
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 0
            },
            "description": "Return events with id greater than this (per-type cursor)."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 100
            },
            "description": "Max events to return (1-100)."
          }
        ],
        "responses": {
          "200": {
            "description": "Events (newest first without `since`; ascending from the cursor with it)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ReadEvent"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Unknown `type`.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "`type=gate` requires the Pro plan. NOTE: this plan check returns a plain-text message (\"This feature requires the pro plan or higher\"), a different 403 body than the `insufficient_scope` message an OAuth token missing `readership:read` receives.\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/gate-responses": {
      "get": {
        "tags": [
          "Events"
        ],
        "summary": "Gate responses (completed viewing-gate forms)",
        "operationId": "listGateResponses",
        "description": "A dedicated resource alias of `/api/v1/events?type=gate` for integrations whose trigger config wants a resource path. Identical rows, cursor, and parameters. Pro plan required; scope `readership:read`. Event ids are 64-bit. Email-only gates (which store the address on the view session rather than as a form response) do not appear in this feed.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "readership:read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "file",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Only this file's responses (UUID or short id). Unknown or foreign identifiers return an empty list."
          },
          {
            "name": "since",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64",
              "default": 0
            },
            "description": "Return responses with id greater than this. Not portable to the other event streams."
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 100
            },
            "description": "Max responses to return (1-100)."
          }
        ],
        "responses": {
          "200": {
            "description": "Gate responses as `type:\"gate\"` events",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ReadEvent"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Requires the Pro plan (plain-text 403 message) - or, for OAuth tokens, the `readership:read` scope (`insufficient_scope` message).\n",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "List your webhooks",
        "operationId": "listWebhooks",
        "description": "Webhook endpoints for the caller's account. Never includes signing secrets (shown once at creation). Scope `webhooks:manage` (API keys hold every scope).",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The caller's webhooks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhooks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Webhook"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Create a webhook",
        "operationId": "createWebhook",
        "description": "Register an HTTPS endpoint to receive signed event deliveries (available on every plan; up to 25 webhooks per account). Set `fileUuid` to scope delivery to one file; omit it for account-wide delivery. The signing secret (`whsec_...`) is returned exactly once, in this response, and is never retrievable again. Deliveries carry an `X-Everypage-Signature` header: `t=<unix ts>,v1=<hex>` where v1 is HMAC-SHA256 over `<t>.<raw body>` keyed with the FULL secret string (the `whsec_` prefix is part of the key). Endpoints failing 20 consecutive deliveries are disabled automatically.\n\nPayload note: when the viewer arrived through a link variant, the `file.viewed` and `file.downloaded` data objects additionally carry `variantUuid` and `variantLabel` (the label is omitted for variants whose label was GDPR-redacted). Both keys are additive - canonical-link events carry neither.\n\nPlan note: `gate.completed` deliveries require the Pro plan at event time; other kinds deliver on every plan.\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created; `secret` is shown only here.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhook": {
                      "$ref": "#/components/schemas/Webhook"
                    },
                    "secret": {
                      "type": "string",
                      "example": "whsec_9f2c..."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid body; `url` not a plain HTTPS URL (or pointing at a private/local address, or over 2048 chars); empty or unknown `events`; bad `format`; or the 25-webhooks-per-account limit reached (code `validation_error`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "`fileUuid` does not name a file you own.",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhooks/{uuid}": {
      "parameters": [
        {
          "name": "uuid",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          },
          "description": "The webhook's UUID from the create/list response."
        }
      ],
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Delete a webhook",
        "operationId": "deleteWebhook",
        "description": "Remove one of your webhooks by UUID; queued deliveries for it are discarded. Scope `webhooks:manage`. A webhook you do not own answers 404.",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted (queued deliveries are discarded)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhooks/{uuid}/test": {
      "parameters": [
        {
          "name": "uuid",
          "in": "path",
          "required": true,
          "schema": {
            "type": "string",
            "format": "uuid"
          },
          "description": "The webhook's UUID from the create/list response."
        }
      ],
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Send a test delivery",
        "operationId": "testWebhook",
        "description": "Synchronously sends a sample `webhook.test` event through the real delivery path (same body format and signature) and reports the outcome. Fires regardless of any per-file scope - it is an endpoint-reachability check with a placeholder file. Rate-limited more tightly than the rest of the API (10 requests/minute per token).\n",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "webhooks:manage"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Test outcome (delivered may be false)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "delivered": {
                      "type": "boolean"
                    },
                    "status": {
                      "type": "integer",
                      "description": "HTTP status your endpoint returned (0 if unreachable)."
                    },
                    "latencyMs": {
                      "type": "integer"
                    },
                    "error": {
                      "type": "string",
                      "description": "Present when not delivered (coarse reason only)."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "description": "Test rate limit exceeded (10 per minute per token) - or the general 120/min limit (code `rate_limited`).",
            "content": {
              "application/problem+json": {
                "schema": {
                  "$ref": "#/components/schemas/Problem"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/user": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Your account and plan",
        "operationId": "getCurrentUser",
        "description": "The caller's profile - `name`, `email`, `subscription` (`free`, `basic`, `pro`), `hasDealLicense` and `created_at`. Scope `profile`. `isAdmin` is always false through the API (the internal flag is masked on every token-authenticated read).",
        "security": [
          {
            "bearerAuth": []
          },
          {
            "oauth2": [
              "profile"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/User"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/oembed": {
      "get": {
        "tags": [
          "Embedding"
        ],
        "summary": "oEmbed provider endpoint",
        "operationId": "getOEmbed",
        "description": "Turn a pasted EveryPage share link into an embeddable rich object (an iframe on the /embed/ path). Public and unauthenticated; rate-limited per IP (60/min). Accepts any share-link form - UUID, short id, or a vanity slug on the owner's custom domain - and the /embed/<id> form. Dead links of every kind (unknown, expired, view limit reached, unclaimed) answer a uniform 404.\n",
        "security": [],
        "parameters": [
          {
            "name": "url",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uri"
            },
            "description": "The full share link to resolve (URL-encoded)."
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "json"
              ]
            },
            "description": "Only json is supported; other values answer 501."
          }
        ],
        "responses": {
          "200": {
            "description": "oEmbed rich payload",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OEmbed"
                }
              }
            }
          },
          "400": {
            "description": "Missing or non-absolute `url` parameter.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "The link does not resolve to a viewable document (unknown, expired, burned or unclaimed).",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimitedPublic"
          },
          "501": {
            "description": "A `format` other than json was requested.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/oauth/authorize": {
      "get": {
        "tags": [
          "OAuth"
        ],
        "summary": "Authorization endpoint (Authorization Code + PKCE)",
        "operationId": "oauthAuthorize",
        "description": "Browser endpoint, not an API call - send the user here to obtain consent for a registered OAuth client. Signed-out users are bounced through login first. On approval it redirects to `redirect_uri` with `?code=` (and `?state=`); protocol errors (`unsupported_response_type`, `invalid_request` for a missing/non-S256 challenge, `invalid_scope`) redirect back with `?error=`. An unknown `client_id` or an unregistered `redirect_uri` cannot be redirected and answers a plain-text 400 instead. PKCE S256 is mandatory; authorization codes are single-use and valid for 5 minutes.",
        "security": [],
        "parameters": [
          {
            "name": "response_type",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "code"
              ]
            },
            "description": "Always `code`."
          },
          {
            "name": "client_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The registered OAuth client id (`epc_...`)."
          },
          {
            "name": "redirect_uri",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Must exactly match a redirect URI registered for the client."
          },
          {
            "name": "scope",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Space-delimited; every scope must be allowed for the client."
          },
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Opaque CSRF value echoed back on the redirect."
          },
          {
            "name": "code_challenge",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "PKCE challenge (RFC 7636)."
          },
          {
            "name": "code_challenge_method",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "S256"
              ]
            },
            "description": "Only `S256` is accepted."
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to the consent screen, login, or back to redirect_uri with code/error."
          },
          "400": {
            "description": "Unknown OAuth client, or a `redirect_uri` that is not registered for the client. Plain text - there is no trusted redirect target to send the error to.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/oauth/token": {
      "post": {
        "tags": [
          "OAuth"
        ],
        "summary": "Token endpoint",
        "operationId": "oauthToken",
        "description": "Exchanges an authorization code (with the PKCE `code_verifier`) or a refresh token for tokens. Client auth via HTTP Basic or body params; confidential clients must present `client_secret`. Refresh tokens rotate: each refresh returns a new `refresh_token` and retires the previous one (replaying a retired token revokes the whole family); pass `scope` to down-scope. Errors are RFC 6749 JSON documents.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "authorization_code",
                      "refresh_token"
                    ]
                  },
                  "code": {
                    "type": "string"
                  },
                  "redirect_uri": {
                    "type": "string"
                  },
                  "code_verifier": {
                    "type": "string"
                  },
                  "refresh_token": {
                    "type": "string"
                  },
                  "scope": {
                    "type": "string",
                    "description": "Optional down-scope on refresh."
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "client_secret": {
                    "type": "string",
                    "description": "Confidential clients only."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string",
                      "example": "ep_at_..."
                    },
                    "token_type": {
                      "type": "string",
                      "example": "Bearer"
                    },
                    "expires_in": {
                      "type": "integer"
                    },
                    "refresh_token": {
                      "type": "string"
                    },
                    "scope": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`invalid_request`, `invalid_grant` (unknown, used, expired or PKCE-mismatched code; revoked or rotated refresh token) or `unsupported_grant_type`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          },
          "401": {
            "description": "`invalid_client` - unknown `client_id`, or a wrong or missing `client_secret` for a confidential client.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          }
        }
      }
    },
    "/oauth/revoke": {
      "post": {
        "tags": [
          "OAuth"
        ],
        "summary": "Revoke a token (RFC 7009)",
        "operationId": "oauthRevoke",
        "description": "Revoke an access or refresh token issued to your OAuth client. Client authentication as on the token endpoint (HTTP Basic or body params). Unknown tokens still answer 200; only client-authentication failures error.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "token_type_hint": {
                    "type": "string",
                    "enum": [
                      "access_token",
                      "refresh_token"
                    ]
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "client_secret": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Always returns 200 (empty body), even for unknown tokens."
          },
          "400": {
            "description": "invalid_request (unparseable form)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          },
          "401": {
            "description": "invalid_client (missing or unknown client_id, or a wrong client_secret)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OAuthError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key (ep_live_...) or OAuth access token (ep_at_...) sent as `Authorization: Bearer <token>`. API keys hold every scope; OAuth tokens are limited to the scopes granted at consent."
      },
      "oauth2": {
        "type": "oauth2",
        "description": "Authorization Code + PKCE (S256 only, no plain). Client authentication on the token and revoke endpoints via HTTP Basic or `client_id`/`client_secret` body params; public clients send `client_id` alone. Refresh tokens rotate on every refresh (the previous one is retired; reusing it revokes the token family) and may be down-scoped with `scope`. Each operation lists the scope it requires.",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://everypage.co/oauth/authorize",
            "tokenUrl": "https://everypage.co/oauth/token",
            "refreshUrl": "https://everypage.co/oauth/token",
            "scopes": {
              "files:read": "See your files and share links",
              "files:write": "Upload, configure, and delete files",
              "readership:read": "See who read your documents",
              "webhooks:manage": "Create, list, test, and delete webhook subscriptions",
              "profile": "See your account email and plan"
            }
          }
        }
      },
      "manageToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "EveryPage manage token",
        "description": "The `manageToken` returned by an anonymous upload or a Canva import, sent as `Authorization: Bearer <manageToken>`. Bound to one file; valid for 30 days and only while that file remains unclaimed - once the file is claimed into an account, token calls answer 404. Token callers act at the free tier: plan-gated settings answer 403 and content replacement (Pro) is always refused. Accepted ONLY by PUT /api/v1/files/{uuid}/settings and POST /api/v1/files/{uuid}/content; every other operation answers 401 to it. Not the `claimToken` (a different credential that attaches the file to an account)."
      },
      "canvaJwt": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "A Canva Apps SDK user token (`auth.getCanvaUserToken()`), verified against Canva JWKS. Accepted only by the /api/v1/canva/* operations, which are called by the EveryPage Canva app; it is not an EveryPage credential and no other operation accepts it."
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Malformed body or multipart, or an invalid value (code `validation_error`).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "No or invalid API key, OAuth token, manage token or claim token (code `missing_token` or `invalid_token`).",
        "headers": {
          "WWW-Authenticate": {
            "description": "RFC 6750 challenge, e.g. `Bearer realm=\"EveryPage API\", error=\"invalid_token\"`.",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Not the owner (code `forbidden`), an OAuth token missing the required scope (`insufficient_scope`, with a `WWW-Authenticate` challenge naming it), a feature above your plan (`plan_required`), a plan limit reached such as file slots or the per-file asset cap (`forbidden`), or - on the two manage-token operations - a token acting at the free tier on a plan-gated field.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "NotFound": {
        "description": "Unknown id, or a file you do not own on routes where 404 replaces 403 (code `not_found`).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "MethodNotAllowed": {
        "description": "The method is not supported on this path (code `method_not_allowed`).",
        "headers": {
          "Allow": {
            "description": "Supported methods.",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Conflict": {
        "description": "Slug already in use, redacted label re-set, file claimed by another account, or a concurrent replace (code `conflict`).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Gone": {
        "description": "Dead link - expired or burned (code `gone`). Settings cannot revive it; a burned link un-burns via content replacement.",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "TooLarge": {
        "description": "The upload (or the PDF a Word/PowerPoint file converted to) is above your plan's size cap - 20MB Free, 200MB Basic, 2GB Pro - or a JSON body is over its fixed cap (1MB for settings and hotspots, 64KB for variants and claims) (code `payload_too_large`).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "Unprocessable": {
        "description": "A Word or PowerPoint file could not be converted to PDF (code `unprocessable`).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Over the limit - 120 requests per minute per Authorization value on /api/v1 (webhook test 10/min; the Canva lane 30/min per IP). Code `rate_limited`; wait for `Retry-After`.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Reset": {
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "ServiceUnavailable": {
        "description": "A dependency (document converter, Canva key verification) is unavailable - retry with backoff (code `service_unavailable`).",
        "content": {
          "application/problem+json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "RateLimitedPublic": {
        "description": "Per-IP rate limit exceeded on a public endpoint (60 requests per minute). Plain-text body; wait for `Retry-After`.",
        "headers": {
          "Retry-After": {
            "description": "Seconds to wait before retrying.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "text/plain": {
            "schema": {
              "type": "string"
            }
          }
        }
      },
      "NotFoundText": {
        "description": "Unknown, expired, trashed or burned file id. Plain-text body: this public endpoint is outside the /api/v1 problem-document lane.",
        "content": {
          "text/plain": {
            "schema": {
              "type": "string"
            }
          }
        }
      }
    },
    "schemas": {
      "ApiIndex": {
        "type": "object",
        "description": "The zero-auth discovery document served at GET /api/v1.",
        "properties": {
          "name": {
            "type": "string",
            "example": "EveryPage API"
          },
          "version": {
            "type": "string",
            "example": "v1"
          },
          "status": {
            "type": "string",
            "example": "ok"
          },
          "openapi": {
            "type": "string",
            "format": "uri"
          },
          "documentation": {
            "type": "string",
            "format": "uri"
          },
          "authentication": {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "example": "bearer"
              },
              "api_keys": {
                "type": "string",
                "format": "uri",
                "description": "Where to create a self-serve API key."
              },
              "oauth_metadata": {
                "type": "string",
                "format": "uri",
                "description": "RFC 8414 authorization-server metadata."
              },
              "resource_metadata": {
                "type": "string",
                "format": "uri",
                "description": "RFC 9728 protected-resource metadata for /api/v1."
              },
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "rate_limit": {
            "type": "object",
            "properties": {
              "requests_per_minute": {
                "type": "integer",
                "example": 120
              },
              "headers": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "errors": {
            "type": "string",
            "example": "application/problem+json"
          },
          "versioning": {
            "type": "string",
            "format": "uri"
          },
          "free_plan": {
            "type": "object",
            "properties": {
              "signup": {
                "type": "string",
                "format": "uri"
              },
              "card_required": {
                "type": "boolean",
                "example": false
              },
              "sandbox": {
                "type": "boolean",
                "example": false,
                "description": "There is no separate sandbox; use a free account."
              }
            }
          }
        }
      },
      "UploadResult": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "shortId": {
            "type": "string",
            "example": "aB3xY9kQ2mZ7",
            "description": "The file's short share-URL alias."
          },
          "shareUrl": {
            "type": "string",
            "example": "https://everypage.co/aB3xY9kQ2mZ7",
            "description": "The ready-to-send share link, built server-side so clients need neither a second round trip nor a copy of the construction rule (which also has to know whether the owner has a verified custom domain). Always the durable short-id form - a just-created file has no vanity slug.\n"
          },
          "message": {
            "type": "string",
            "example": "PDF uploaded successfully"
          }
        }
      },
      "File": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "shortId": {
            "type": "string",
            "example": "aB3xY9kQ2mZ7",
            "description": "Short share-URL alias; absent on files created before short IDs."
          },
          "slug": {
            "type": "string",
            "example": "2026-pricing",
            "description": "Vanity link name (Pro); absent unless set. Resolves only on the owner's custom domain."
          },
          "originalName": {
            "type": "string",
            "example": "proposal.pdf"
          },
          "size": {
            "type": "integer",
            "format": "int64",
            "description": "Bytes"
          },
          "mimeType": {
            "type": "string",
            "example": "application/pdf"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "deleteAt": {
            "type": "string",
            "format": "date-time",
            "description": "Absent when the file never expires."
          },
          "viewCount": {
            "type": "integer"
          },
          "totalPages": {
            "type": "integer"
          },
          "viewerMode": {
            "type": "string",
            "enum": [
              "standard",
              "flipbook",
              "swipe",
              "magazine"
            ]
          },
          "protected": {
            "type": "boolean",
            "description": "True if a password is set."
          },
          "allowDownload": {
            "type": "boolean"
          },
          "requireEmail": {
            "type": "boolean"
          },
          "notifyOnView": {
            "type": "boolean"
          },
          "commentsEnabled": {
            "type": "boolean",
            "description": "Reader feedback notes are on (Pro to enable)."
          },
          "commentsAllowPublic": {
            "type": "boolean",
            "description": "Commenters may opt into public display of their note."
          },
          "notifyOnComment": {
            "type": "boolean",
            "description": "The owner is emailed (throttled) when a note arrives."
          },
          "askReceipt": {
            "type": "boolean",
            "description": "Viewers are asked to confirm receipt (Basic+ to enable)."
          },
          "marksEnabled": {
            "type": "boolean",
            "description": "Proofing page marks are on (Pro to enable)."
          },
          "annotationsEnabled": {
            "type": "boolean",
            "description": "Proofing annotations are on (Pro to enable)."
          },
          "viewLimit": {
            "type": "integer",
            "description": "Self-destruct cap (Basic+). Absent when unlimited."
          },
          "viewsConsumed": {
            "type": "integer",
            "description": "Views consumed against `viewLimit` (always present, 0 when no limit)."
          },
          "burnedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Present once the view-limit sweep destroyed the blob; the row and its readership survive, and content replacement un-burns it."
          },
          "gateDomains": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Email-gate domain allowlist (Pro). Absent when any domain is accepted."
          },
          "gateFields": {
            "type": "array",
            "items": {
              "type": "object",
              "additionalProperties": true
            },
            "description": "The stored lead-capture form definition (Pro). Absent when the gate is the plain email prompt."
          },
          "watermark": {
            "type": "boolean",
            "description": "Viewer pages are stamped with the reader identity (Pro)."
          },
          "viewerSettings": {
            "type": "object",
            "additionalProperties": true,
            "description": "The stored appearance/behaviour blob exactly as saved (see the FileSettings.viewerSettings description); `{}` when never customised. Always present for the owner regardless of current plan."
          },
          "shareDomain": {
            "type": "string",
            "example": "docs.example.com",
            "description": "The owner's verified Pro custom domain that share links and embeds are built on. Absent when links use everypage.co."
          },
          "folderId": {
            "type": "string",
            "format": "uuid",
            "description": "UUID of the folder the file is filed in. Absent when unfiled."
          },
          "anonShareable": {
            "type": "boolean",
            "description": "True for owner-less files that are public-by-link (browser anonymous uploads, Canva imports). Always false once claimed."
          },
          "revisions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Revision"
            },
            "description": "Replacement history - superseded content versions, newest first. Populated only by GET /api/v1/files/{uuid}; absent in the list and when the content was never replaced."
          },
          "pageRange": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PageRange"
              }
            ],
            "description": "Present when a page range limits non-owner viewers (absent = the full document serves). Enforced regardless of your current plan (see the setting's description); owners always get the full document.\n"
          },
          "contentVersion": {
            "type": "integer",
            "description": "Counts in-place content replacements (1 = the original upload)."
          },
          "palette": {
            "$ref": "#/components/schemas/Palette"
          },
          "canvaDesignId": {
            "type": "string",
            "description": "The Canva design this file was published from. Absent on files with no design binding (including after a detach)."
          },
          "canvaUpdatesEnabled": {
            "type": "boolean",
            "description": "Whether a Canva-side republish of the bound design may replace this file's bytes (see the setting). Present only alongside canvaDesignId."
          }
        },
        "description": "The owner's view of a file (`FileResponse`). Optional fields are omitted when empty rather than sent as null. `revisions` is populated only by the single-file GET, never in the list."
      },
      "Palette": {
        "type": "array",
        "description": "Colour palette extracted from the document at import time: hex swatches (\"#rrggbb\"), dominant colour first, at most 6. ADVISORY ONLY - extraction is best-effort (first-page embedded raster images, short time budget), so the field is simply absent for vector-only documents, for files uploaded through non-import paths, for files created before the feature, and whenever extraction fails or times out. Absence is normal, never an error; use the palette for theming (e.g. styling a share card), never for logic.\n",
        "items": {
          "type": "string",
          "example": "#1a2b3c"
        }
      },
      "ImportResult": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "shortId": {
            "type": "string",
            "example": "aB3xY9kQ2mZ7"
          },
          "shareUrl": {
            "type": "string",
            "example": "https://everypage.co/aB3xY9kQ2mZ7"
          },
          "republished": {
            "type": "boolean",
            "description": "True when the Canva design lane replaced an existing file's bytes in place (the uuid/shortId are the EXISTING link's); false/absent on a plain create."
          },
          "contentVersion": {
            "type": "integer",
            "description": "The file's content version after a republish (present only when republished is true; increments on every in-place replacement)."
          },
          "palette": {
            "$ref": "#/components/schemas/Palette"
          }
        }
      },
      "CanvaImportResult": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "shortId": {
            "type": "string",
            "example": "aB3xY9kQ2mZ7"
          },
          "viewURL": {
            "type": "string",
            "example": "https://everypage.co/aB3xY9kQ2mZ7"
          },
          "qrURL": {
            "type": "string",
            "description": "PNG QR code for the share link."
          },
          "claimToken": {
            "type": "string",
            "description": "Attaches the anonymous file to an account via /api/claim. Expires after 30 days. Republishes mint fresh tokens too."
          },
          "manageToken": {
            "type": "string",
            "description": "Authorizes the v1 settings endpoint while the file remains anonymous. A distinct credential from claimToken; expires after 30 days."
          },
          "republished": {
            "type": "boolean",
            "description": "True when this publish replaced the earlier file's bytes in place (same link, same QR); false on a create."
          },
          "contentVersion": {
            "type": "integer",
            "description": "The file's content version after a republish (present only when republished is true)."
          },
          "palette": {
            "$ref": "#/components/schemas/Palette"
          }
        }
      },
      "CanvaDesignStatus": {
        "type": "object",
        "description": "The publish state of (calling Canva user, design). Fields beyond `published`/`publishedByOther` appear only when a live link exists, and `uuid`/`viewCount`/`contentVersion`/`claimToken` are further restricted - see the endpoint description.\n",
        "properties": {
          "published": {
            "type": "boolean",
            "description": "The calling Canva user has a live link for this design."
          },
          "publishedByOther": {
            "type": "boolean",
            "description": "A DIFFERENT Canva user has a live link for this design (shared/team designs). Boolean only - nothing about the other link is disclosed."
          },
          "claimed": {
            "type": "boolean",
            "description": "The link belongs to an EveryPage account (present when published)."
          },
          "uuid": {
            "type": "string",
            "format": "uuid",
            "description": "Present when the link is unclaimed, or the presented accessToken is the owner's."
          },
          "shortId": {
            "type": "string",
            "example": "aB3xY9kQ2mZ7"
          },
          "viewURL": {
            "type": "string",
            "example": "https://everypage.co/aB3xY9kQ2mZ7"
          },
          "qrURL": {
            "type": "string",
            "description": "PNG QR code for the share link."
          },
          "viewCount": {
            "type": "integer",
            "description": "Present when unclaimed, or the presented accessToken is the owner's."
          },
          "contentVersion": {
            "type": "integer",
            "description": "Same visibility as viewCount. Increments on every in-place republish."
          },
          "claimToken": {
            "type": "string",
            "description": "Freshly minted, ONLY while the link is unclaimed (30-day validity). Redeem via POST /api/v1/files/claim or the /api/claim browser link."
          },
          "connectedAccountIsOwner": {
            "type": "boolean",
            "description": "Present only when an accessToken was sent and a link exists: whether that account owns the (claimed) link. Always false while unclaimed."
          }
        }
      },
      "ClaimResult": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "shortId": {
            "type": "string",
            "example": "aB3xY9kQ2mZ7"
          },
          "viewURL": {
            "type": "string",
            "example": "https://everypage.co/aB3xY9kQ2mZ7"
          }
        }
      },
      "PageRange": {
        "type": "object",
        "description": "A non-owner page window, 1-based and inclusive. `{\"from\":0,\"to\":0}` is the explicit \"no range\" value (clears the file-level range; as a variant override it serves the full document through that variant).\n",
        "properties": {
          "from": {
            "type": "integer",
            "minimum": 0,
            "description": "First shared page (1-based)."
          },
          "to": {
            "type": "integer",
            "minimum": 0,
            "maximum": 20000,
            "description": "Last shared page (inclusive; must be >= from)."
          }
        }
      },
      "FileSettings": {
        "type": "object",
        "description": "All fields optional; omitted fields keep their current value.",
        "properties": {
          "viewerMode": {
            "type": "string",
            "enum": [
              "standard",
              "flipbook",
              "swipe",
              "magazine"
            ]
          },
          "password": {
            "type": "string",
            "description": "Non-empty to protect (Basic+); empty string to clear."
          },
          "allowDownload": {
            "type": "boolean"
          },
          "requireEmail": {
            "type": "boolean",
            "description": "Gate viewing behind an email (Pro)."
          },
          "notifyOnView": {
            "type": "boolean",
            "description": "Email you on each read (Basic+)."
          },
          "deleteAt": {
            "type": "string",
            "format": "date-time",
            "description": "Fixed expiry. Uncapped for signed-in accounts on every plan (null = never expires); manage-token callers on an anonymous file are capped at 7 days. Set through a MANAGE TOKEN on an anonymous file, it also marks the expiry as deliberately chosen, so a Canva republish keeps it instead of re-basing to the 365-day default - a one-way mark (the token lane has no way to hand expiry back to the system)."
          },
          "neverExpire": {
            "type": "boolean",
            "description": "Permanent link, available to every signed-in plan (not through a manage token). Overrides deleteAt."
          },
          "viewLimit": {
            "type": "integer",
            "description": "Self-destruct after this many views (Basic+). 0 clears the limit; omit to keep. A burned link keeps its row and readership - un-burn it via content replacement."
          },
          "gateDomains": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Email-gate domain allowlist (Pro to set a non-empty list; [] clears on any plan)."
          },
          "gateFields": {
            "type": "array",
            "items": {
              "type": "object"
            },
            "nullable": true,
            "description": "Lead-capture form definition shown instead of the plain email prompt (Pro to set; null or [] clears on any plan). A non-empty form forces requireEmail on."
          },
          "askReceipt": {
            "type": "boolean",
            "description": "Ask viewers to confirm receipt (Basic+ to enable)."
          },
          "commentsEnabled": {
            "type": "boolean",
            "description": "Reader feedback notes (Pro to enable)."
          },
          "commentsAllowPublic": {
            "type": "boolean",
            "description": "Let commenters opt into public display."
          },
          "notifyOnComment": {
            "type": "boolean",
            "description": "Email you (throttled) when a note arrives."
          },
          "marksEnabled": {
            "type": "boolean",
            "description": "Proofing page marks (Pro to enable)."
          },
          "annotationsEnabled": {
            "type": "boolean",
            "description": "Proofing annotations (Pro to enable)."
          },
          "watermark": {
            "type": "boolean",
            "description": "Stamp viewer pages (Pro). Watermarked downloads are limited to files up to 100MB (400 above that)."
          },
          "slug": {
            "type": "string",
            "description": "Vanity link name on your custom domain (Pro): lowercase letters, digits, and hyphens; \"\" clears. 409 when another of your files already uses it."
          },
          "canvaUpdatesEnabled": {
            "type": "boolean",
            "description": "May a Canva-side republish of the bound design replace this file's bytes? Defaults true; only meaningful on files with a canvaDesignId. Un-gated on every plan in both directions - it is a safety control, not a feature."
          },
          "detachCanvaDesign": {
            "type": "boolean",
            "description": "True permanently severs the file from the Canva design that published it: no future publish of the design can update this link (the next publish creates a fresh one). One-way - there is no re-bind through settings - and un-gated on every plan. No-op on unbound files."
          },
          "viewerSettings": {
            "type": "object",
            "description": "The viewer appearance/behavior blob. Blocks: `background` (type solid|gradient|image with color/gradient preset/assetId, fit, blur 0-20, dim 0-80), `logo` (assetId, position tl|tr|bl|br, size s|m|l|xl, linkUrl, hideBadge), `page` (shadow 0-3, rounded 0-3, edges, coverAlone), `flip` (speedMs 200-1200, sound, rtl, layout adaptive|single|double), `swipe` (autoAdvance, intervalMs 3000-30000), `protect` (contextMenu/print/select Basic+; blurOnLeave Pro), `ga4Id` (Pro), `brand` (accentColor, toolbarTheme, badgePosition; Pro), `cta` (label 1-40 chars, https url, style, color, position; Pro), and `watermarkStyle` (opacity 0.05-0.5, density sparse|normal|dense; Pro). Any deviation from the defaults requires Basic+; unknown fields are dropped and out-of-range values clamped. Tier gates are re-applied at serve time, so a downgraded plan's Pro styling stops rendering for viewers immediately.\n"
          },
          "pageRange": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PageRange"
              }
            ],
            "description": "Limit non-owner viewers to a page window: pages outside it are trimmed server-side before any bytes are served (the hidden pages never reach the browser; the public metadata advertises the active window as `pageRange {from,to,totalPages}` so viewers see an honest \"preview ends here\" card). Omit to keep the current range; `{\"from\":0,\"to\":0}` clears it; a valid pair (1 <= from <= to) sets it. SETTING a range requires the Pro plan and a file of at most 100MB (400 above that); CLEARING is never plan-gated. Enforcement deliberately survives a Pro downgrade: a lapsed subscription stops you setting new ranges but never widens access to pages an existing link already hides - clear the range to serve the full document again. You, the owner, always receive the full document. A per-variant `overrides.pageRange` beats this file-level range for viewers of that variant. Note pdfcpu's trim applies a reduced feature set to the served window (annotations and outlines are dropped).\n"
          }
        }
      },
      "ReadEvent": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64",
            "description": "Stable event id for polling/dedup. Per-stream: not comparable across `type` values."
          },
          "fileUuid": {
            "type": "string",
            "format": "uuid"
          },
          "fileName": {
            "type": "string"
          },
          "readAt": {
            "type": "string",
            "format": "date-time"
          },
          "country": {
            "type": "string",
            "example": "United Kingdom",
            "description": "Absent on gate events."
          },
          "pagesViewed": {
            "type": "integer",
            "description": "view events only (always present there, 0 included); absent on download/gate events."
          },
          "timeMs": {
            "type": "integer",
            "description": "Time spent reading, milliseconds. view events only; absent on download/gate events."
          },
          "type": {
            "type": "string",
            "enum": [
              "view",
              "download",
              "gate"
            ],
            "description": "Which stream the event belongs to."
          },
          "fields": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "gate events only: the submitted form fields (e.g. email, name)."
          }
        }
      },
      "Variant": {
        "type": "object",
        "description": "A per-recipient child link of a file.",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "shortId": {
            "type": "string",
            "example": "aB3xY9kQ2mZ7",
            "description": "The variant's own share-URL alias."
          },
          "url": {
            "type": "string",
            "example": "https://everypage.co/aB3xY9kQ2mZ7",
            "description": "The full shareable link (the owner's custom domain when one is verified)."
          },
          "label": {
            "type": "string",
            "example": "Jane at Acme",
            "description": "Recipient tag. Absent when never set - and, permanently, after redaction."
          },
          "overrides": {
            "$ref": "#/components/schemas/VariantOverrides"
          },
          "revoked": {
            "type": "boolean",
            "description": "A revoked variant's link answers 404 to viewers until un-revoked."
          },
          "redactedAt": {
            "type": "string",
            "format": "date-time",
            "description": "Present after GDPR redaction (the label is gone for good)."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "VariantOverrides": {
        "type": "object",
        "description": "Per-variant setting overrides applied to viewers arriving through this variant. Omitted keys inherit the file's settings. On update the object is stored exactly as sent (whole-object replace, never a per-key merge).",
        "properties": {
          "allowDownload": {
            "type": "boolean",
            "description": "Override the file's explicit-download permission for this variant's viewers."
          },
          "pageRange": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PageRange"
              }
            ],
            "description": "Override the file-level page range for this variant's viewers: a valid pair (1 <= from <= to; file at most 100MB) replaces the file's range outright, and the explicit `{\"from\":0,\"to\":0}` serves the FULL document through this variant even when the file has a range. Omitted, the file-level range (if any) applies. The variant-effective window is what the variant's public metadata advertises and what its viewers' bytes are trimmed to; like the file-level range it keeps enforcing after a Pro downgrade.\n"
          }
        }
      },
      "VariantCreate": {
        "type": "object",
        "properties": {
          "label": {
            "type": "string",
            "maxLength": 200,
            "description": "Optional recipient tag (shown in readership and webhook payloads; erasable via redaction)."
          },
          "overrides": {
            "$ref": "#/components/schemas/VariantOverrides"
          }
        }
      },
      "VariantUpdate": {
        "type": "object",
        "description": "All fields optional; omitted fields keep their current value. One exception in granularity: `overrides`, when present, REPLACES the stored override set as a whole object - it is never merged key by key. Send every override you want to keep: `{\"overrides\": {\"allowDownload\": true}}` also removes a stored `pageRange` override, `{\"overrides\": {}}` removes all overrides, and only omitting `overrides` entirely leaves the stored set untouched.\n",
        "properties": {
          "label": {
            "type": "string",
            "maxLength": 200,
            "description": "New label; empty string clears it. Rejected (409) on a redacted variant."
          },
          "overrides": {
            "$ref": "#/components/schemas/VariantOverrides"
          },
          "revoked": {
            "type": "boolean",
            "description": "true revokes (viewers get 404), false un-revokes."
          }
        }
      },
      "Webhook": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "url": {
            "type": "string",
            "example": "https://example.com/hooks/everypage"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "example": [
              "file.viewed",
              "gate.completed"
            ]
          },
          "format": {
            "type": "string",
            "enum": [
              "json",
              "slack"
            ]
          },
          "active": {
            "type": "boolean",
            "description": "False only after automatic disable for repeated delivery failures."
          },
          "consecutiveFailures": {
            "type": "integer"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "fileUuid": {
            "type": "string",
            "format": "uuid",
            "description": "Present when delivery is scoped to one file; absent for account-wide webhooks."
          }
        }
      },
      "WebhookCreate": {
        "type": "object",
        "required": [
          "url",
          "events"
        ],
        "properties": {
          "url": {
            "type": "string",
            "description": "Plain HTTPS URL (no credentials, no private/local addresses)."
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "file.viewed",
                "file.downloaded",
                "gate.completed",
                "note.created",
                "receipt.confirmed",
                "file.burned",
                "content.replaced",
                "invite.viewed",
                "proofing.updated"
              ]
            },
            "description": "Event kinds to deliver (at least one). `gate.completed` deliveries require the Pro plan at event time; other kinds deliver on every plan.\n"
          },
          "format": {
            "type": "string",
            "enum": [
              "json",
              "slack"
            ],
            "default": "json"
          },
          "fileUuid": {
            "type": "string",
            "description": "Optional: deliver only this file's events (UUID or short id; must be a file you own)."
          }
        }
      },
      "User": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string",
            "format": "email"
          },
          "subscription": {
            "type": "string",
            "enum": [
              "free",
              "basic",
              "pro"
            ]
          },
          "isAdmin": {
            "type": "boolean",
            "description": "Always false through the API: the internal admin flag is masked on every token-authenticated read, so third-party apps never see it."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "hasDealLicense": {
            "type": "boolean",
            "description": "True for lifetime-deal holders with an active deal license (their entitlement comes from the license rather than a subscription)."
          }
        }
      },
      "OEmbed": {
        "type": "object",
        "description": "Standard oEmbed 1.0 rich-type response.",
        "properties": {
          "version": {
            "type": "string",
            "example": "1.0"
          },
          "type": {
            "type": "string",
            "example": "rich"
          },
          "provider_name": {
            "type": "string",
            "example": "EveryPage"
          },
          "provider_url": {
            "type": "string",
            "example": "https://everypage.co"
          },
          "title": {
            "type": "string",
            "example": "proposal.pdf"
          },
          "html": {
            "type": "string",
            "description": "An <iframe> on the durable /embed/ path (short id or UUID - never the renameable slug)."
          },
          "width": {
            "type": "integer",
            "example": 800
          },
          "height": {
            "type": "integer",
            "example": 600
          }
        }
      },
      "Problem": {
        "type": "object",
        "description": "RFC 9457 Problem Details, returned with `Content-Type: application/problem+json` for every error under /api/v1 and for unknown /api paths. Branch on `code` (or `status`), never on `detail`. `type` links to the matching section of https://everypage.co/docs/developers/authentication#error-responses.",
        "required": [
          "type",
          "title",
          "status",
          "detail",
          "code"
        ],
        "properties": {
          "type": {
            "type": "string",
            "format": "uri",
            "example": "https://everypage.co/docs/developers/authentication#insufficient-scope"
          },
          "title": {
            "type": "string",
            "description": "The HTTP reason phrase.",
            "example": "Forbidden"
          },
          "status": {
            "type": "integer",
            "minimum": 400,
            "maximum": 599,
            "example": 403
          },
          "detail": {
            "type": "string",
            "description": "The human-readable message for this occurrence.",
            "example": "insufficient_scope: requires files:write"
          },
          "instance": {
            "type": "string",
            "format": "uri",
            "description": "Absolute URL of the request that failed.",
            "example": "https://everypage.co/api/v1/files"
          },
          "code": {
            "type": "string",
            "description": "Stable machine code. Values not listed here are the HTTP reason phrase in lower snake case and may be added without notice.",
            "enum": [
              "missing_token",
              "invalid_token",
              "insufficient_scope",
              "plan_required",
              "validation_error",
              "forbidden",
              "not_found",
              "method_not_allowed",
              "conflict",
              "gone",
              "payload_too_large",
              "unsupported_media_type",
              "unprocessable",
              "rate_limited",
              "internal_error",
              "service_unavailable"
            ]
          },
          "hint": {
            "type": "string",
            "description": "What to do next.",
            "example": "Reconnect with a token that grants files:write. API keys (ep_live_) hold every scope."
          }
        }
      },
      "OAuthError": {
        "type": "object",
        "description": "RFC 6749 error document returned by the /oauth/token and /oauth/revoke endpoints.",
        "properties": {
          "error": {
            "type": "string",
            "example": "invalid_client"
          },
          "error_description": {
            "type": "string"
          }
        }
      },
      "DeleteResult": {
        "type": "object",
        "required": [
          "trashed"
        ],
        "properties": {
          "trashed": {
            "type": "boolean",
            "description": "True when the file went to (or was already in) the trash; false after a hard delete (Free plan, or `?purge=1`)."
          },
          "trashedAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the file entered the trash. Present only when `trashed` is true."
          },
          "purgeAt": {
            "type": "string",
            "format": "date-time",
            "description": "When the trash sweep will hard-delete it: `trashedAt` + 7 days (Basic) or 30 days (Pro). Present only when `trashed` is true."
          }
        }
      },
      "ReplaceResult": {
        "type": "object",
        "properties": {
          "uuid": {
            "type": "string",
            "format": "uuid"
          },
          "contentVersion": {
            "type": "integer",
            "description": "The new content version (increments on every in-place replacement)."
          },
          "originalName": {
            "type": "string",
            "description": "The stored display name - the uploaded filename, with a `.pdf` extension after an Office conversion."
          },
          "size": {
            "type": "integer",
            "format": "int64",
            "description": "Bytes of the stored PDF."
          },
          "anchorsCleared": {
            "type": "boolean",
            "description": "Whether page-anchored hotspots and notes were cleared (the `clearAnchors` decision)."
          },
          "clearedHotspots": {
            "type": "integer",
            "description": "Hotspots removed by the replacement."
          },
          "clearedNotes": {
            "type": "integer",
            "description": "Page-anchored notes removed by the replacement."
          },
          "message": {
            "type": "string",
            "example": "Content replaced"
          }
        }
      },
      "Revision": {
        "type": "object",
        "description": "One superseded content version of a file (see `File.revisions`).",
        "properties": {
          "version": {
            "type": "integer",
            "description": "The content version this entry was (1 = the original upload)."
          },
          "originalName": {
            "type": "string"
          },
          "size": {
            "type": "integer",
            "format": "int64",
            "description": "Bytes"
          },
          "totalPages": {
            "type": "integer"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time",
            "description": "When this version was uploaded."
          }
        }
      },
      "ReadershipReport": {
        "type": "object",
        "description": "The owner's readership dashboard data. `file`, `tier` and `summary` are always present; the other sections follow the OWNER's plan (`tier`): Basic adds `effectivePages`, `viewsOverTime`, `funnel`, `referrers`, `browsers`, `sources`, `viewerModes`, `peakTimes`, `devices` and `countries`; Pro adds per-viewer `sessions` (with `variants` when the file has link variants) and, when configured, `hotspotTaps` and `ctaTaps`. Sections above the plan are omitted, not empty.",
        "required": [
          "file",
          "tier",
          "summary"
        ],
        "properties": {
          "file": {
            "$ref": "#/components/schemas/File"
          },
          "tier": {
            "type": "string",
            "enum": [
              "free",
              "basic",
              "pro"
            ],
            "description": "The owner's plan, which decides which sections are present."
          },
          "summary": {
            "type": "object",
            "properties": {
              "totalViews": {
                "type": "integer"
              },
              "totalSessions": {
                "type": "integer"
              },
              "uniqueVisitors": {
                "type": "integer"
              },
              "uniqueCountries": {
                "type": "integer"
              },
              "avgTimeMs": {
                "type": "integer"
              },
              "downloads": {
                "type": "integer"
              }
            }
          },
          "variants": {
            "type": "array",
            "description": "Pro only, when the file has link variants: per-variant readership.",
            "items": {
              "type": "object",
              "properties": {
                "uuid": {
                  "type": "string",
                  "format": "uuid"
                },
                "label": {
                  "type": "string",
                  "description": "Omitted after GDPR redaction."
                },
                "sessions": {
                  "type": "integer"
                },
                "avgTimeMs": {
                  "type": "integer"
                },
                "revoked": {
                  "type": "boolean"
                },
                "redacted": {
                  "type": "boolean"
                }
              }
            }
          }
        },
        "additionalProperties": true
      }
    },
    "parameters": {
      "fileId": {
        "name": "uuid",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|(?=.*[A-Z])[A-Za-z0-9]{12})$"
        },
        "description": "The file's UUID or its 12-character short id (base62 with at least one uppercase letter - the last path segment of the share link, e.g. `aB3xY9kQ2mZ7`). Case-sensitive. Vanity slugs and link-variant short ids are NOT accepted here: a variant short id names a child link, not the file, and answers 404."
      }
    }
  }
}
