openapi: 3.0.3
info:
  title: EveryPage API
  description: >
    Upload PDFs and get trackable share links programmatically. Authenticate
    with a personal API key (create one at https://everypage.co/account under
    API keys) sent as a bearer token.
  version: "1.0.0"
  contact:
    email: support@everypage.co
servers:
  - url: https://everypage.co
security:
  - bearerAuth: []
tags:
  - name: Files
  - name: Variants
  - name: Events
  - name: Webhooks
  - name: Account
  - name: Embedding
paths:
  /api/v1/files:
    post:
      tags: [Files]
      summary: Upload a PDF
      description: Upload a PDF as multipart form-data. Returns the new file's UUID and short ID; the share link is `https://everypage.co/{shortId}` (the UUID form works too).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The PDF file. Must be application/pdf.
      responses:
        "200":
          description: Uploaded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadResult"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { $ref: "#/components/responses/TooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
    get:
      tags: [Files]
      summary: List your files
      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" }
  /api/v1/files/import:
    post:
      tags: [Files]
      summary: Import a PDF from an export URL
      description: >
        Create a PDF in your account by server-side download from an
        allowlisted export URL (e.g. a Canva design export). The URL must be
        plain HTTPS on the import host allowlist; the download is PDF-only,
        redirect-free, and capped at your plan's size limit. Scope
        `files:write`.


        The 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.
      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. }
      responses:
        "200":
          description: Imported
          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, or your plan's file limit is reached. }
        "413": { $ref: "#/components/responses/TooLarge" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { description: The export source could not be fetched or returned an error. }
  /api/v1/canva/import:
    post:
      tags: [Files]
      summary: Anonymous Canva import (Canva app credential)
      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.
      security: []
      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. }
      responses:
        "200":
          description: Imported
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CanvaImportResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { description: Missing or invalid Canva user token. }
        "403": { description: The URL's host is not an allowed import source. }
        "413": { description: The export is larger than the anonymous upload limit. }
        "429": { description: Per-Canva-user rate limit or upload budget exceeded. }
        "502": { description: The export source could not be fetched or returned an error. }
  /api/v1/files/{uuid}:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Files]
      summary: Get a file's details
      responses:
        "200":
          description: File details
          content:
            application/json:
              schema: { $ref: "#/components/schemas/File" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Files]
      summary: Delete a file
      responses:
        "200": { description: Deleted }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/files/{uuid}/settings:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      tags: [Files]
      summary: Update a file's settings
      description: >
        Set the viewer mode, password, download permission, and expiry. Omitted
        fields keep their current value. Some settings are plan-gated and return
        403 below the required plan.


        Besides 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.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FileSettings" }
      responses:
        "200": { description: Settings applied }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: The requested slug is already used by another of your files. }
        "410": { description: "The link is already dead - expired, or burned by its view limit. Settings cannot revive a dead link (extend expiry BEFORE it lapses; a burned link un-burns only via content replacement)." }
  /api/v1/files/{uuid}/content:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Files]
      summary: Replace the document behind a link (dynamic links)
      description: >
        Swap the PDF behind a share link in place - the UUID, short id, slug,
        QR code, settings, and readership history all survive; only the bytes
        change. Pro plan required. Multipart, same shape as the upload
        endpoint; `?clearAnchors=false` keeps page-anchored hotspots and notes
        despite the page mismatch (default clears them).


        Accepts 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.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The replacement PDF.
      responses:
        "200": { description: Content replaced (returns the new contentVersion) }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "410": { description: "The link has expired; expired links refuse replacement content. (Burned links are NOT refused - replacing the content is the documented un-burn path.)" }
        "413": { $ref: "#/components/responses/TooLarge" }
  /api/v1/files/{uuid}/variants:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string }, description: The parent file (UUID or short id). }
    get:
      tags: [Variants]
      summary: List a file's link variants
      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`.
      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" }
    post:
      tags: [Variants]
      summary: Create a link variant
      description: >
        Mint a per-recipient child link for a file you own: it gets its own
        short id and URL, an optional recipient `label`, and optional
        per-variant setting `overrides` (`allowDownload` and `pageRange`). Viewers
        arriving through the variant are attributed to it in the readership
        breakdown and in `file.viewed`/`file.downloaded` webhook payloads
        (`variantUuid`/`variantLabel`). Pro plan required; scope
        `files:write`; up to 200 variants per file.


        Passwords 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.
      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. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/files/{uuid}/variants/{variantUuid}:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string }, description: The parent file (UUID or short id). }
      - { name: variantUuid, in: path, required: true, schema: { type: string, format: uuid } }
    put:
      tags: [Variants]
      summary: Update a link variant
      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).
      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": { description: The variant was redacted; its label cannot be set again. }
    delete:
      tags: [Variants]
      summary: Delete or redact a link variant
      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.
      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" }
  /api/v1/files/{uuid}/readership:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string }, description: The file (UUID or short id). }
    get:
      tags: [Events]
      summary: A file's readership report
      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`.
      responses:
        "200":
          description: Readership report (plan-shaped; `variants` present on Pro when the file has link variants)
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/files/{uuid}/qr-code:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Files]
      summary: QR code for the share link
      description: >
        Bearer-authed twin of the public `/api/files/{uuid}/qr-code` endpoint.
        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.
      responses:
        "200":
          description: PNG QR code encoding the share URL
          content:
            image/png:
              schema: { type: string, format: binary }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/files/{uuid}/qr-code:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Files]
      summary: QR code for the share link (public)
      security: []
      description: >
        Unauthenticated QR endpoint (per-IP rate limited). This is the URL
        integration outputs should embed - e.g. the Zapier `qr_url` output -
        so anything downstream can fetch the PNG without credentials.
      responses:
        "200":
          description: PNG QR code encoding the share URL
          content:
            image/png:
              schema: { type: string, format: binary }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/events:
    get:
      tags: [Events]
      summary: Recent events
      description: >
        Events across your files, newest first. Three streams selected by
        `type`: `view` (the default - view sessions), `download` (explicit
        save-to-disk fetches), and `gate` (completed viewing-gate form
        submissions; Pro plan required). Each event has a stable `id`; poll
        with `since` to fetch only newer events.

        Cursors are per-stream: each `type` has its own independent id
        sequence, so a `since` value from one stream is meaningless in
        another - never mix them. (`view`/`download` ids are 32-bit; `gate`
        ids are 64-bit.) There is no `type=all`.

        Note: the `download` stream records every explicit download,
        including your own - unlike the `file.downloaded` webhook, which
        only fires for non-owner downloads.
      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`. }
        "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.
  /api/v1/gate-responses:
    get:
      tags: [Events]
      summary: Gate responses (completed viewing-gate forms)
      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.
      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).
  /api/v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List your webhooks
      description: Webhook endpoints for the caller's account. Never includes signing secrets (shown once at creation). Scope `webhooks:manage` (API keys hold every scope).
      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" }
    post:
      tags: [Webhooks]
      summary: Create a webhook
      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.


        Payload 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.


        Plan note: `gate.completed` deliveries require the Pro plan at event
        time; other kinds deliver on every plan.
      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": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { description: "`fileUuid` does not name a file you own." }
  /api/v1/webhooks/{uuid}:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      responses:
        "204": { description: Deleted (queued deliveries are discarded). }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/v1/webhooks/{uuid}/test:
    parameters:
      - { name: uuid, in: path, required: true, schema: { type: string, format: uuid } }
    post:
      tags: [Webhooks]
      summary: Send a test delivery
      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).
      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/minute per token). }
  /api/v1/user:
    get:
      tags: [Account]
      summary: Your account and plan
      responses:
        "200":
          description: Account
          content:
            application/json:
              schema: { $ref: "#/components/schemas/User" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /oembed:
    get:
      tags: [Embedding]
      summary: oEmbed provider endpoint
      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.
      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. }
        "404": { description: The link does not resolve to a viewable document. }
        "501": { description: A format other than json was requested. }
  /oauth/authorize:
    get:
      tags: [OAuth]
      summary: Authorization endpoint (Authorization Code + PKCE)
      description: Redirect the user's browser here to obtain consent. On approval, redirects back to redirect_uri with ?code= (and ?state=).
      security: []
      parameters:
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string } }
        - { name: scope, in: query, required: true, schema: { type: string }, description: Space-delimited. }
        - { name: state, in: query, required: false, schema: { type: string } }
        - { name: code_challenge, in: query, required: true, schema: { type: string } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
      responses:
        "302": { description: Redirect to the consent screen, login, or back to redirect_uri with code/error. }
  /oauth/token:
    post:
      tags: [OAuth]
      summary: Token endpoint
      description: Exchanges an authorization code (with PKCE code_verifier) or a refresh token for tokens. Client auth via HTTP Basic or body params.
      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 }
                  token_type: { type: string, example: Bearer }
                  expires_in: { type: integer }
                  refresh_token: { type: string }
                  scope: { type: string }
        "400": { description: invalid_request / invalid_grant / unsupported_grant_type }
        "401": { description: invalid_client }
  /oauth/revoke:
    post:
      tags: [OAuth]
      summary: Revoke a token (RFC 7009)
      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, even for unknown tokens. }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "An API key (ep_live_...) or OAuth access token (ep_at_...) sent as: Authorization: Bearer <token>"
    oauth2:
      type: oauth2
      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
  responses:
    BadRequest:
      description: Invalid request (e.g. not a PDF, malformed body).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing, malformed, invalid, or revoked API key.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Not allowed (ownership, or a setting above your plan).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: No such file (or not owned by you).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    TooLarge:
      description: The file is larger than your plan allows.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    RateLimited:
      description: Rate limit exceeded (120 requests per minute per key).
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
  schemas:
    UploadResult:
      type: object
      properties:
        uuid: { type: string, format: uuid }
        shortId: { type: string, example: "aB3xY9kQ2mZ7", description: The file's short share-URL alias. }
        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, nullable: true, 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 }
        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.
        contentVersion: { type: integer, description: Counts in-place content replacements (1 = the original upload). }
        palette: { $ref: "#/components/schemas/Palette" }
    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.
      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" }
        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. }
        manageToken: { type: string, description: Authorizes the v1 settings endpoint while the file remains anonymous. A distinct credential from claimToken; expires after 30 days. }
        palette: { $ref: "#/components/schemas/Palette" }
    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).
      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. Capped by plan: free 7 days, basic 365 days." }
        neverExpire: { type: boolean, description: Permanent link (Pro). 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." }
        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.
        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).
    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.
    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.
      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.
        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 }
        created_at: { type: string, format: date-time }
    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 }
    Error:
      type: object
      description: Errors are returned as a plain-text message with the HTTP status.
      properties:
        message: { type: string }
