openapi: 3.1.0
info:
  title: MetricSpot REST API
  version: "2026-05-16"
  summary: Programmatic SEO audits, white-label PDF reports, and HTML previews.
  description: |
    The MetricSpot REST API lets you run on-demand SEO audits, generate
    white-label PDF reports, and render browser-friendly HTML previews for
    any URL you own.

    Access requires the Pro plan ($49/month) and is capped at 5,000 API
    calls per calendar month across all of your tokens. Quota resets on
    the 1st of every month.

    Most endpoints expect a Bearer API token prefixed with `ms_live_`.
    The single anonymous endpoint (`POST /api/public/audit`) is open to
    the world and rate-limited by IP.
  contact:
    name: MetricSpot Support
    email: info@metricspot.com
    url: https://app.metricspot.com
  license:
    name: Proprietary

servers:
  - url: https://app.metricspot.com
    description: Production

tags:
  - name: public
    description: Anonymous endpoints with IP-based rate limits. No auth.
  - name: audits
    description: Create, list, fetch, and manage SEO audits.
  - name: pdfs
    description: Generate, poll, and download white-label PDF reports.

security:
  - BearerAuth: []

paths:
  /api/public/audit:
    post:
      tags: [public]
      summary: Run an anonymous audit
      description: |
        Runs a lightweight SEO audit synchronously and returns the result in
        the response body. Does not persist a normal audit record and skips
        Core Web Vitals (PageSpeed Insights). Rate-limited to 1 request per
        IP per 24 hours.
      operationId: anonymousAudit
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StartAuditRequest'
            examples:
              basic:
                summary: Audit example.com
                value:
                  url: https://example.com
      responses:
        '200':
          description: Audit completed inline.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnonymousAuditResponse'
              examples:
                completed:
                  summary: Completed anonymous audit
                  value:
                    audit:
                      url: https://example.com
                      domain: example.com
                      total_score: 72
                      module_scores:
                        meta: 90
                        headings: 70
                        links: 65
                        images: 80
                        performance: 0
                        structured_data: 60
                        accessibility: 75
                        security: 100
                        crawlability: 80
                        content: 55
                        organic_traffic: 100
                      rules:
                        - module: meta
                          rule_id: title_present
                          passed: true
                          severity: minor
                          title: Page title is present
                          recommendation: null
                          data: {}
                    note: Anonymous audits are limited and do not include Core Web Vitals. Sign up free for full audits and PDF reports.
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          description: Per-IP rate limit reached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                rateLimit:
                  value:
                    error: Rate limit reached
                    message: Sign up free for more reports.
        '502':
          description: Upstream audit run failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                failed:
                  value:
                    error: Audit failed
                    message: Target URL refused the connection.

  /api/audits:
    post:
      tags: [audits]
      summary: Start an authenticated audit
      description: |
        Queues a full audit for the given URL. The job runs asynchronously;
        poll `GET /api/audits/{id}` to track progress. Subject to per-plan
        monthly caps, per-URL cooldowns, and the API quota.
      operationId: startAudit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StartAuditRequest'
            examples:
              basic:
                value:
                  url: https://example.com
      responses:
        '201':
          description: Audit row created and worker queued.
          content:
            application/json:
              schema:
                type: object
                required: [audit]
                properties:
                  audit:
                    $ref: '#/components/schemas/AuditQueued'
              examples:
                queued:
                  value:
                    audit:
                      id: 123456
                      domain: example.com
                      url: https://example.com
                      status: queued
                      score: null
                      created_at: "2026-05-16T10:00:00.000Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PlanCap'
        '403':
          $ref: '#/components/responses/PlanLimit'
        '429':
          $ref: '#/components/responses/QuotaOrCooldown'
    get:
      tags: [audits]
      summary: List your audits
      description: |
        Returns the most recent audit per URL for the authenticated user,
        sorted by `display_position` (if set via `PATCH /api/audits/reorder`)
        then by `created_at` descending. Capped at 100 rows.
      operationId: listAudits
      responses:
        '200':
          description: Audit list (one row per unique URL).
          content:
            application/json:
              schema:
                type: object
                required: [audits]
                properties:
                  audits:
                    type: array
                    items:
                      $ref: '#/components/schemas/AuditSummary'
              examples:
                list:
                  value:
                    audits:
                      - id: 123456
                        domain: example.com
                        url: https://example.com
                        status: completed
                        score: 72
                        created_at: "2026-05-16T10:00:00.000Z"
                        completed_at: "2026-05-16T10:00:45.000Z"
                        display_position: 0
                        has_ga: true
                        has_gsc: false
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/PlanLimit'
        '429':
          $ref: '#/components/responses/Quota'

  /api/audits/usage:
    get:
      tags: [audits]
      summary: Get current audit usage and plan
      description: |
        Returns the user's audit count for the current calendar month,
        their plan cap (null when unlimited), subscription status, and
        the timestamp the counter resets.
      operationId: getAuditUsage
      responses:
        '200':
          description: Usage snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageSnapshot'
              examples:
                proPlan:
                  value:
                    used: 12
                    cap: 500
                    resets_at: "2026-06-01T00:00:00.000Z"
                    plan: pro
                    status: active
                    trial_ends_at: null
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/PlanLimit'
        '429':
          $ref: '#/components/responses/Quota'

  /api/audits/history:
    get:
      tags: [audits]
      summary: Audit history for a URL
      description: Returns up to the 50 most recent audits this user has run for the given `url`.
      operationId: getAuditHistory
      parameters:
        - in: query
          name: url
          required: true
          description: Exact URL to look up. Maximum 2000 characters.
          schema:
            type: string
            format: uri
            maxLength: 2000
      responses:
        '200':
          description: Audit history rows, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [audits]
                properties:
                  audits:
                    type: array
                    items:
                      $ref: '#/components/schemas/AuditHistoryRow'
              examples:
                history:
                  value:
                    audits:
                      - id: 123456
                        domain: example.com
                        url: https://example.com
                        status: completed
                        score: 72
                        created_at: "2026-05-16T10:00:00.000Z"
                        completed_at: "2026-05-16T10:00:45.000Z"
                      - id: 122000
                        domain: example.com
                        url: https://example.com
                        status: completed
                        score: 68
                        created_at: "2026-05-09T10:00:00.000Z"
                        completed_at: "2026-05-09T10:00:42.000Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/PlanLimit'
        '429':
          $ref: '#/components/responses/Quota'

  /api/audits/reorder:
    patch:
      tags: [audits]
      summary: Reorder dashboard audit list
      description: |
        Sets `display_position` on the latest audit per URL so the dashboard
        list (and `GET /api/audits`) sorts by your preferred order. URLs not
        in the array keep their existing position. Maximum 200 entries.
      operationId: reorderAudits
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url_order]
              properties:
                url_order:
                  type: array
                  maxItems: 200
                  items:
                    type: string
                    maxLength: 2000
            examples:
              twoUrls:
                value:
                  url_order:
                    - https://example.com
                    - https://example.org
      responses:
        '204':
          description: Reorder applied.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/PlanLimit'
        '429':
          $ref: '#/components/responses/Quota'

  /api/audits/{id}:
    get:
      tags: [audits]
      summary: Get audit detail
      description: |
        Returns the full audit row plus its findings, the user's effective
        plan, and the per-URL cooldown state.
      operationId: getAudit
      parameters:
        - $ref: '#/components/parameters/AuditId'
      responses:
        '200':
          description: Audit detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuditDetail'
              examples:
                detail:
                  value:
                    audit:
                      id: 123456
                      user_id: 42
                      domain: example.com
                      url: https://example.com
                      status: completed
                      score: 72
                      audit_version: 14
                      raw:
                        module_scores:
                          meta: 90
                          headings: 70
                      perf_status: ok
                      error_message: null
                      started_at: "2026-05-16T10:00:01.000Z"
                      completed_at: "2026-05-16T10:00:45.000Z"
                      created_at: "2026-05-16T10:00:00.000Z"
                    findings:
                      - id: 9001
                        module: meta
                        rule_id: title_present
                        severity: minor
                        passed: true
                        title: Page title is present
                        recommendation: null
                        data: {}
                    plan: pro
                    cooldown:
                      cooldown_seconds: 0
                      available_at: null
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/Quota'
    delete:
      tags: [audits]
      summary: Delete an audit (and all history for its URL)
      description: |
        Deletes every audit row this user has for the same URL as the given
        audit id, not just the targeted row. The dashboard de-duplicates by
        URL, so removing only the latest would surface a stale older audit.
      operationId: deleteAudit
      parameters:
        - $ref: '#/components/parameters/AuditId'
      responses:
        '204':
          description: Audit history for this URL removed.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/Quota'

  /api/audits/{id}/pdf:
    post:
      tags: [pdfs]
      summary: Queue a PDF report
      description: |
        Queues a white-label PDF render for a completed audit. Pass an
        optional `language` (defaults to the user's account language, then
        `en`) and an optional `brand_id` for white-label styling. If
        `brand_id` is omitted and the account has one brand kit, it is used
        automatically. Poll `GET /api/pdfs/{id}` for status.
      operationId: startPdf
      parameters:
        - $ref: '#/components/parameters/AuditId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StartPdfRequest'
            examples:
              defaults:
                summary: Default branding, default language
                value: {}
              spanishBranded:
                summary: Spanish, explicit brand kit
                value:
                  language: es
                  brand_id: 17
      responses:
        '201':
          description: PDF render queued.
          content:
            application/json:
              schema:
                type: object
                required: [pdf]
                properties:
                  pdf:
                    $ref: '#/components/schemas/PdfQueued'
              examples:
                queued:
                  value:
                    pdf:
                      id: 88
                      audit_id: 123456
                      brand_id: 17
                      language: en
                      status: pending
                      created_at: "2026-05-16T10:05:00.000Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PlanCap'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Audit is not yet complete.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notReady:
                  value:
                    error: Audit not yet complete
        '429':
          $ref: '#/components/responses/Quota'

  /api/audits/{id}/report:
    get:
      tags: [pdfs]
      summary: HTML preview of the audit report
      description: |
        Returns a self-contained HTML document with the same data and
        template as the PDF. Useful for in-browser QA of white-label
        styling before generating a real download. The response is marked
        `Cache-Control: private, no-store` because the underlying audit
        data is authentication-gated.
      operationId: previewReport
      parameters:
        - $ref: '#/components/parameters/AuditId'
        - in: query
          name: lang
          required: false
          description: Locale for the report copy. Defaults to `en`.
          schema:
            type: string
            example: en
        - in: query
          name: brand_id
          required: false
          description: Brand kit to apply. Omit for default styling.
          schema:
            type: integer
            minimum: 1
            example: 17
      responses:
        '200':
          description: Rendered HTML preview.
          content:
            text/html:
              schema:
                type: string
                description: Self-contained HTML document.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PlanCap'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Audit is not yet complete.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/Quota'

  /api/pdfs/{id}:
    get:
      tags: [pdfs]
      summary: Get PDF status or download
      description: |
        Default behavior returns JSON with the render status. If the
        request sets `Accept: application/pdf` AND the PDF is `ready`, the
        endpoint streams the binary PDF instead.
      operationId: getPdf
      parameters:
        - $ref: '#/components/parameters/PdfId'
        - in: header
          name: Accept
          required: false
          description: |
            Use `application/json` (default) to poll status, or
            `application/pdf` to download the binary when ready.
          schema:
            type: string
            example: application/json
      responses:
        '200':
          description: Either a JSON status payload or the binary PDF, based on `Accept`.
          content:
            application/json:
              schema:
                type: object
                required: [pdf]
                properties:
                  pdf:
                    $ref: '#/components/schemas/PdfStatus'
              examples:
                pending:
                  value:
                    pdf:
                      id: 88
                      audit_id: 123456
                      status: pending
                      error_message: null
                      created_at: "2026-05-16T10:05:00.000Z"
                      generated_at: null
                      download_url: null
                ready:
                  value:
                    pdf:
                      id: 88
                      audit_id: 123456
                      status: ready
                      error_message: null
                      created_at: "2026-05-16T10:05:00.000Z"
                      generated_at: "2026-05-16T10:05:30.000Z"
                      download_url: /api/pdfs/88/download
            application/pdf:
              schema:
                type: string
                format: binary
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          description: PDF was generated but the file is no longer on disk.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                gone:
                  value:
                    error: File missing on disk
        '429':
          $ref: '#/components/responses/Quota'

  /api/pdfs/{id}/download:
    get:
      tags: [pdfs]
      summary: Download a ready PDF
      description: |
        Streams the binary PDF as `application/pdf` with a
        `Content-Disposition: attachment` header. The PDF must be in status
        `ready` and the file must still exist on disk.
      operationId: downloadPdf
      parameters:
        - $ref: '#/components/parameters/PdfId'
      responses:
        '200':
          description: PDF binary.
          headers:
            Content-Disposition:
              schema:
                type: string
                example: attachment; filename="example.com-audit.pdf"
          content:
            application/pdf:
              schema:
                type: string
                format: binary
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: PDF is not yet ready.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                notReady:
                  value:
                    error: PDF not ready
        '410':
          description: PDF row exists but the file is no longer on disk.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                gone:
                  value:
                    error: File missing
        '429':
          $ref: '#/components/responses/Quota'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ms_live_<random>
      description: |
        Bearer token issued from the dashboard under Account, API tokens.
        Tokens are prefixed with `ms_live_` and pass plan and quota gates
        on every request.

  parameters:
    AuditId:
      in: path
      name: id
      required: true
      description: Audit row id.
      schema:
        type: integer
        minimum: 1
        example: 123456
    PdfId:
      in: path
      name: id
      required: true
      description: PDF report row id.
      schema:
        type: integer
        minimum: 1
        example: 88

  responses:
    BadRequest:
      description: Malformed request (bad id, missing or invalid body, bad query param).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            badId:
              value:
                error: Bad id
            invalidBody:
              value:
                error: Invalid JSON body
    Unauthorized:
      description: Missing or invalid bearer token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            badToken:
              value:
                error: Invalid API token
    Forbidden:
      description: Authenticated but not allowed to access this resource.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notOwner:
              value:
                error: Forbidden
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notFound:
              value:
                error: Not found
    PlanLimit:
      description: |
        Authenticated, but the user's plan does not include API access.
        API access requires the Pro plan.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            apiAccess:
              value:
                error: plan_limit
                message: API access requires the Pro plan. Upgrade at /pricing.
    PlanCap:
      description: |
        Plan-specific limit reached (monthly audit cap, white-label
        restriction, paid-plan-only feature).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/PlanLimitError'
          examples:
            auditCap:
              value:
                error: Plan limit reached
                code: audit_cap
                message: Your Pro plan includes 500 audits/month. You've used 500 this month.
                plan: pro
                used: 500
                cap: 500
                resets_at: "2026-06-01T00:00:00.000Z"
            whiteLabel:
              value:
                error: Plan limit reached
                message: White-label PDFs require Starter or Pro.
                plan: free
    Quota:
      description: API call quota exceeded for the current calendar month.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            quota:
              value:
                error: quota_exceeded
                message: Monthly API quota of 5000 calls reached. Resets on the 1st.
                used: 5000
                limit: 5000
    QuotaOrCooldown:
      description: |
        Either the monthly API call quota was exceeded or an audit
        cooldown is active (per-URL or global).
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/Error'
              - $ref: '#/components/schemas/CooldownError'
          examples:
            quota:
              value:
                error: quota_exceeded
                message: Monthly API quota of 5000 calls reached. Resets on the 1st.
                used: 5000
                limit: 5000
            cooldown:
              value:
                error: Audit cooldown
                message: "You can re-audit this URL after 2026-05-17T10:00:00.000Z."
                available_at: "2026-05-17T10:00:00.000Z"
                cooldown_seconds: 86400

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Short machine-readable error code or label.
        message:
          type: string
          description: Human-readable explanation when present.
        code:
          type: string
          description: Optional secondary code used by some plan-gate errors.
        used:
          type: integer
          description: Quota usage so far (present on quota responses).
        limit:
          type: integer
          description: Quota cap (present on quota responses).
      additionalProperties: true

    PlanLimitError:
      type: object
      required: [error]
      properties:
        error:
          type: string
        code:
          type: string
        message:
          type: string
        plan:
          $ref: '#/components/schemas/PlanId'
        used:
          type: integer
        cap:
          type: integer
        resets_at:
          type: string
          format: date-time
      additionalProperties: true

    CooldownError:
      type: object
      required: [error, available_at, cooldown_seconds]
      properties:
        error:
          type: string
          example: Audit cooldown
        code:
          type: string
          example: global_cooldown
        message:
          type: string
        available_at:
          type: string
          format: date-time
        cooldown_seconds:
          type: integer

    PlanId:
      type: string
      enum: [free, starter, premium, pro]

    AuditStatus:
      type: string
      enum: [queued, running, completed, failed]

    Severity:
      type: string
      enum: [info, minor, major, critical]

    StartAuditRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2000
          example: https://example.com

    AuditQueued:
      description: Row returned immediately after queuing an audit.
      type: object
      required: [id, domain, url, status, created_at]
      properties:
        id:
          type: integer
          example: 123456
        domain:
          type: string
          example: example.com
        url:
          type: string
          format: uri
          example: https://example.com
        status:
          $ref: '#/components/schemas/AuditStatus'
        score:
          type: [integer, "null"]
          description: Total score, null until the audit completes.
        created_at:
          type: string
          format: date-time

    AuditSummary:
      description: Row returned by `GET /api/audits` (one per unique URL).
      type: object
      required: [id, domain, url, status, created_at]
      properties:
        id:
          type: integer
        domain:
          type: string
        url:
          type: string
          format: uri
        status:
          $ref: '#/components/schemas/AuditStatus'
        score:
          type: [integer, "null"]
        created_at:
          type: string
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time
        display_position:
          type: [integer, "null"]
          description: Sort key set via `PATCH /api/audits/reorder`.
        has_ga:
          type: boolean
          description: True when this URL has a Google Analytics property linked.
        has_gsc:
          type: boolean
          description: True when this URL has a Search Console site linked.

    AuditHistoryRow:
      description: Row returned by `GET /api/audits/history`.
      type: object
      required: [id, domain, url, status, created_at]
      properties:
        id:
          type: integer
        domain:
          type: string
        url:
          type: string
          format: uri
        status:
          $ref: '#/components/schemas/AuditStatus'
        score:
          type: [integer, "null"]
        created_at:
          type: string
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time

    Audit:
      description: Full audit row returned inside `AuditDetail.audit`.
      type: object
      required: [id, user_id, domain, url, status, audit_version, created_at]
      properties:
        id:
          type: integer
        user_id:
          type: integer
        domain:
          type: string
        url:
          type: string
          format: uri
        status:
          $ref: '#/components/schemas/AuditStatus'
        score:
          type: [integer, "null"]
        audit_version:
          type: integer
          description: Incrementing schema version of the audit engine that produced this row.
        raw:
          type: object
          description: |
            Raw audit payload. Includes `module_scores` and engine-specific
            data. Shape evolves with `audit_version`.
          additionalProperties: true
        perf_status:
          type: [string, "null"]
          description: PageSpeed Insights subsystem status (e.g. `ok`, `skipped`, `error`).
        error_message:
          type: [string, "null"]
        started_at:
          type: [string, "null"]
          format: date-time
        completed_at:
          type: [string, "null"]
          format: date-time
        created_at:
          type: string
          format: date-time

    Finding:
      type: object
      required: [id, module, rule_id, severity, passed, title]
      properties:
        id:
          type: integer
        module:
          type: string
          description: Module the rule belongs to (e.g. `meta`, `headings`, `images`).
        rule_id:
          type: string
        severity:
          $ref: '#/components/schemas/Severity'
        passed:
          type: boolean
        title:
          type: string
        recommendation:
          type: [string, "null"]
        data:
          type: object
          description: Free-form rule data (matched elements, counts, sample URLs, etc.).
          additionalProperties: true

    AuditDetail:
      type: object
      required: [audit, findings, plan, cooldown]
      properties:
        audit:
          $ref: '#/components/schemas/Audit'
        findings:
          type: array
          items:
            $ref: '#/components/schemas/Finding'
        plan:
          $ref: '#/components/schemas/PlanId'
        cooldown:
          type: object
          required: [cooldown_seconds, available_at]
          properties:
            cooldown_seconds:
              type: integer
              description: Plan-configured cooldown window in seconds. 0 means no cooldown.
            available_at:
              type: [string, "null"]
              format: date-time
              description: ISO timestamp when the next audit for this URL is allowed, or null if available now.

    AnonymousAuditRule:
      type: object
      required: [module, rule_id, passed, severity, title]
      properties:
        module:
          type: string
        rule_id:
          type: string
        passed:
          type: boolean
        severity:
          $ref: '#/components/schemas/Severity'
        title:
          type: string
        recommendation:
          type: [string, "null"]
        data:
          type: object
          additionalProperties: true

    AnonymousAuditResponse:
      type: object
      required: [audit, note]
      properties:
        audit:
          type: object
          required: [url, domain, total_score, module_scores, rules]
          properties:
            url:
              type: string
              format: uri
            domain:
              type: string
            total_score:
              type: integer
            module_scores:
              type: object
              additionalProperties:
                type: integer
            rules:
              type: array
              items:
                $ref: '#/components/schemas/AnonymousAuditRule'
        note:
          type: string

    StartPdfRequest:
      type: object
      properties:
        language:
          type: string
          description: Locale code for the report copy (e.g. `en`, `es`, `fr`, `de`, `it`). Defaults to the account language, then `en`.
        brand_id:
          type: [integer, "null"]
          description: Brand kit id. Omit or null to use default styling (or the account's only brand kit when present).
      additionalProperties: false

    PdfStatusEnum:
      type: string
      enum: [pending, rendering, ready, failed]

    PdfQueued:
      type: object
      required: [id, audit_id, language, status, created_at]
      properties:
        id:
          type: integer
        audit_id:
          type: integer
        brand_id:
          type: [integer, "null"]
        language:
          type: string
          example: en
        status:
          $ref: '#/components/schemas/PdfStatusEnum'
        created_at:
          type: string
          format: date-time

    PdfStatus:
      description: "Polling response for `GET /api/pdfs/{id}` with `Accept: application/json`."
      type: object
      required: [id, audit_id, status]
      properties:
        id:
          type: integer
        audit_id:
          type: integer
        status:
          $ref: '#/components/schemas/PdfStatusEnum'
        error_message:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        generated_at:
          type: [string, "null"]
          format: date-time
        download_url:
          type: [string, "null"]
          description: Populated only when status is `ready`. Relative URL to the download endpoint.

    UsageSnapshot:
      type: object
      required: [used, cap, resets_at, plan, status, trial_ends_at]
      properties:
        used:
          type: integer
          description: Audits run by this user in the current calendar month.
        cap:
          type: [integer, "null"]
          description: Monthly audit cap. null means unlimited (admin or trialing).
        resets_at:
          type: string
          format: date-time
          description: ISO timestamp at the start of next month.
        plan:
          $ref: '#/components/schemas/PlanId'
        status:
          type: [string, "null"]
          description: Active subscription status (e.g. `active`, `trialing`, `past_due`, `canceled`), or null if none.
        trial_ends_at:
          type: [string, "null"]
          format: date-time
          description: ISO timestamp; non-null only while `status` is `trialing`.
