openapi: 3.1.0
info:
  # Do not change the title; generated package exports expect Api.
  title: Api
  version: 0.1.0
  description: Paid AI visibility scan API
servers:
  - url: /api
    description: Base API path
tags:
  - name: health
  - name: surface
  - name: audit
  - name: auth
  - name: headless
  - name: mcp
  - name: analytics
paths:
  /healthz:
    get:
      operationId: healthCheck
      tags: [health]
      summary: Health check
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HealthStatus" }

  /version:
    get:
      operationId: getVersion
      tags: [health]
      summary: Runtime version metadata
      responses:
        "200":
          description: Version metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Version" }

  /surface-check:
    post:
      operationId: createSurfaceCheck
      tags: [surface]
      summary: Check cheap public AI/search prep signals before the paid scan
      description: >
        Runs a lightweight static prep check against public URL-level signals.
        This does not call paid AI/search providers and does not prove AI
        visibility; the paid audit scan remains the proof workflow.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SurfaceCheckBody" }
      responses:
        "200":
          description: Surface prep check result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SurfaceCheck" }
        "400":
          description: Invalid URL
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /audit:
    post:
      operationId: createAudit
      tags: [audit]
      summary: Legacy audit endpoint; paid scans require checkout
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAuditBody" }
      responses:
        "402":
          description: Payment required before scan execution
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /audit/checkout:
    post:
      operationId: createAuditCheckout
      tags: [audit]
      summary: Create a Stripe Checkout session for one paid scan
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateAuditBody" }
      responses:
        "200":
          description: Checkout session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditCheckoutSession" }
        "400":
          description: Invalid scan request
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: Billing is not configured
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /audit/{auditId}:
    get:
      operationId: getAudit
      tags: [audit]
      summary: Get a paid scan report by ID
      parameters:
        - in: path
          name: auditId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Scan report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Audit" }
        "404":
          description: Scan not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Sign in required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /audit/{auditId}/rescan:
    post:
      operationId: createAuditRescan
      tags: [audit]
      summary: Start the included re-scan or create a paid re-scan checkout
      parameters:
        - in: path
          name: auditId
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Re-scan started or checkout session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditRescanSession" }
        "401":
          description: Sign in required
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Original scan is not complete
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /auth/checkout-session:
    post:
      operationId: authorizeCheckoutSession
      tags: [auth]
      summary: Verify a paid Stripe return session and create a report access session
      requestBody:
        required: true
        content:
          application/json:
            schema:
              { $ref: "#/components/schemas/AuthorizeCheckoutSessionBody" }
      responses:
        "200":
          description: Checkout session verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportAccessSession" }
        "401":
          description: Checkout session could not be verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /auth/otp/start:
    post:
      operationId: startReportAccessOtp
      tags: [auth]
      summary: Send an email or SMS code for paid report access
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StartReportAccessOtpBody" }
      responses:
        "200":
          description: Code send accepted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }
        "400":
          description: Invalid contact
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /auth/otp/verify:
    post:
      operationId: verifyReportAccessOtp
      tags: [auth]
      summary: Verify an email or SMS code and create a report access session
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/VerifyReportAccessOtpBody" }
      responses:
        "200":
          description: Code verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportAccessSession" }
        "401":
          description: Invalid or expired code
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /auth/session:
    get:
      operationId: getReportAccessSession
      tags: [auth]
      summary: Get current report access session
      responses:
        "200":
          description: Current session
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportAccessSessionStatus" }

  /auth/logout:
    post:
      operationId: logoutReportAccess
      tags: [auth]
      summary: Clear current report access session
      responses:
        "200":
          description: Logged out
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResponse" }

  /account/reports:
    get:
      operationId: listAccountReports
      tags: [auth]
      summary: List paid reports for the signed-in email or phone
      responses:
        "200":
          description: Paid reports
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ReportList" }
        "401":
          description: Not signed in
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /headless/capabilities:
    get:
      operationId: getHeadlessCapabilities
      tags: [headless]
      summary: Discover machine-readable WhoCites headless capabilities
      responses:
        "200":
          description: Current headless API, MCP tool surface, setup guide, and public proof boundaries
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeadlessCapabilities" }

  /mcp:
    get:
      operationId: getMcpDiscovery
      tags: [mcp]
      summary: Discover the hosted WhoCites MCP endpoint
      responses:
        "200":
          description: MCP transport, auth notes, setup guide, public proof boundaries, and tool metadata
          content:
            application/json:
              schema: { $ref: "#/components/schemas/McpDiscovery" }
    post:
      operationId: postMcpJsonRpc
      tags: [mcp]
      summary: Hosted MCP JSON-RPC endpoint
      description: >
        Supports initialize, ping, tools/list, and tools/call for the
        WhoCites MCP tool surface. whocites_start_scan returns Stripe Checkout;
        whocites_start_rescan starts the included post-fix re-scan or returns
        paid re-scan checkout; scan-provider calls start only after payment
        succeeds. Owned report, fix, and re-scan tools require report ownership
        through a session cookie or Bearer key.
      security:
        - reportSessionCookie: []
        - bearerAuth: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/McpJsonRpcRequest" }
      responses:
        "200":
          description: JSON-RPC response
          content:
            application/json:
              schema: { $ref: "#/components/schemas/McpJsonRpcResponse" }
        "202":
          description: Notification accepted without JSON-RPC response

  /headless/verify/{token}:
    get:
      operationId: verifyHeadlessPublicScan
      tags: [headless]
      summary: Verify an opted-in public WhoCites scan
      parameters:
        - in: path
          name: token
          required: true
          schema:
            type: string
            pattern: "^[A-Za-z0-9_-]{16,64}$"
      responses:
        "200":
          description: Public scan proof without private report details
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeadlessPublicScan" }
        "404":
          description: Verification token not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /headless/scans/{auditId}:
    get:
      operationId: getHeadlessOwnedReport
      tags: [headless]
      summary: Get an owned report as agent-safe JSON
      security:
        - reportSessionCookie: []
        - bearerAuth: []
      parameters:
        - in: path
          name: auditId
          required: true
          schema: { type: string, minLength: 1, maxLength: 120 }
      responses:
        "200":
          description: Owned report summary for agents and connectors
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeadlessOwnedReport" }
        "401":
          description: Report ownership could not be verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: Report not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /headless/scans/{auditId}/fixes:
    get:
      operationId: getHeadlessOwnedFixes
      tags: [headless]
      summary: Get builder-ready fixes for an owned report
      security:
        - reportSessionCookie: []
        - bearerAuth: []
      parameters:
        - in: path
          name: auditId
          required: true
          schema: { type: string, minLength: 1, maxLength: 120 }
      responses:
        "200":
          description: Agent-safe fix list for an owned report
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeadlessFixesResponse" }
        "401":
          description: Report ownership could not be verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: Report not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /headless/scans/{auditId}/rescan:
    post:
      operationId: createHeadlessAuditRescan
      tags: [headless]
      summary: Start the included re-scan or create a paid re-scan checkout for an owned report
      security:
        - reportSessionCookie: []
        - bearerAuth: []
      parameters:
        - in: path
          name: auditId
          required: true
          schema: { type: string, minLength: 1, maxLength: 120 }
      responses:
        "200":
          description: Re-scan started or checkout session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuditRescanSession" }
        "401":
          description: Report ownership could not be verified
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "404":
          description: Report not found
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "409":
          description: Original scan is not complete
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /analytics/collect:
    post:
      operationId: collectPageView
      tags: [analytics]
      summary: Collect an anonymous page view
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PageViewEvent" }
      responses:
        "204":
          description: Collected

  /analytics/event:
    post:
      operationId: collectFunnelEvent
      tags: [analytics]
      summary: Collect an allowlisted funnel event
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FunnelEvent" }
      responses:
        "204":
          description: Collected

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        Server-issued WhoCites headless API key. Keys are configured by
        sha256 hash in HEADLESS_API_KEYS_JSON and map to one owning email or
        phone identity.
    reportSessionCookie:
      type: apiKey
      in: cookie
      name: whocites_session
      description: HMAC-signed report access session cookie created by checkout return or OTP login.

  schemas:
    HealthStatus:
      type: object
      required: [status]
      properties:
        status: { type: string }

    Version:
      type: object
      required: [commit, deployedAt]
      properties:
        commit: { type: string }
        commitShort: { type: string, nullable: true }
        branch: { type: string, nullable: true }
        environment: { type: string, nullable: true }
        deploymentId: { type: string, nullable: true }
        serviceName: { type: string, nullable: true }
        deployedAt: { type: string, nullable: true }

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error: { type: string }
        code: { type: string }

    OkResponse:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean }

    ReportAccessChannel:
      type: string
      enum: [email, sms]

    ReportAccessIdentity:
      type: object
      properties:
        email: { type: string, format: email }
        phone: { type: string }

    StartReportAccessOtpBody:
      type: object
      required: [channel]
      properties:
        channel: { $ref: "#/components/schemas/ReportAccessChannel" }
        email: { type: string, format: email }
        phone: { type: string }

    VerifyReportAccessOtpBody:
      allOf:
        - $ref: "#/components/schemas/StartReportAccessOtpBody"
        - type: object
          required: [code]
          properties:
            code:
              type: string
              pattern: "^\\d{6}$"

    AuthorizeCheckoutSessionBody:
      type: object
      required: [auditId, sessionId]
      properties:
        auditId: { type: string }
        sessionId:
          type: string
          pattern: "^cs_(test|live)_[A-Za-z0-9_]+$"

    ReportAccessSession:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean }
        identity: { $ref: "#/components/schemas/ReportAccessIdentity" }

    ReportAccessSessionStatus:
      type: object
      required: [authenticated]
      properties:
        authenticated: { type: boolean }
        identity:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/ReportAccessIdentity"

    ReportSummary:
      type: object
      required: [id, status, domain, brandName, createdAt]
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, running, completed, failed] }
        domain: { type: string }
        brandName: { type: string }
        visibilityScore: { type: number, nullable: true }
        createdAt: { type: string, format: date-time }
        completedAt: { type: string, format: date-time, nullable: true }

    ReportList:
      type: object
      required: [reports]
      properties:
        reports:
          type: array
          items: { $ref: "#/components/schemas/ReportSummary" }

    LegalNotice:
      type: object
      required:
        [version, termsUrl, privacyUrl, refundUrl, purchaseTerms, notice]
      properties:
        version: { type: string }
        termsUrl: { type: string, format: uri }
        privacyUrl: { type: string, format: uri }
        refundUrl: { type: string, format: uri }
        purchaseTerms: { type: string }
        notice: { type: string }

    HeadlessCapabilityEndpoint:
      type: object
      required: [method, path, auth, description]
      properties:
        method: { type: string, enum: [GET, POST] }
        path: { type: string }
        auth: { type: string }
        description: { type: string }

    HeadlessCapabilities:
      type: object
      required:
        - product
        - version
        - openApi
        - setupGuide
        - legal
        - builderGuides
        - proof
        - endpoints
        - mcpTools
        - planned
      properties:
        product: { type: string }
        version: { type: integer }
        openApi: { type: string }
        setupGuide: { type: string }
        legal: { $ref: "#/components/schemas/LegalNotice" }
        builderGuides:
          type: array
          items: { type: string }
        proof:
          type: object
          additionalProperties: true
        endpoints:
          type: array
          items: { $ref: "#/components/schemas/HeadlessCapabilityEndpoint" }
        mcpTools:
          type: array
          items: { type: string }
        planned:
          type: array
          items: { type: string }
        auth:
          type: object
          additionalProperties: { type: string }

    McpTool:
      type: object
      required: [name, title, description, inputSchema]
      properties:
        name: { type: string }
        title: { type: string }
        description: { type: string }
        inputSchema:
          type: object
          additionalProperties: true
        annotations:
          type: object
          additionalProperties: true

    McpDiscovery:
      type: object
      required:
        - product
        - transport
        - protocolVersion
        - endpoint
        - openApi
        - llms
        - setupGuide
        - legal
        - builderGuides
        - repairLoop
        - proof
        - tools
        - auth
        - toolNames
      properties:
        product: { type: string }
        transport: { type: string }
        protocolVersion: { type: string }
        endpoint: { type: string }
        openApi: { type: string }
        llms: { type: string }
        setupGuide: { type: string }
        legal: { $ref: "#/components/schemas/LegalNotice" }
        builderGuides:
          type: array
          items: { type: string }
        repairLoop: { type: string }
        proof:
          type: object
          additionalProperties: true
        tools:
          type: array
          items: { $ref: "#/components/schemas/McpTool" }
        auth:
          type: object
          additionalProperties: true
        example:
          type: object
          additionalProperties: true
        toolNames:
          type: array
          items: { type: string }

    McpJsonRpcRequest:
      oneOf:
        - type: object
          required: [jsonrpc, method]
          properties:
            jsonrpc: { type: string, const: "2.0" }
            id:
              oneOf:
                - { type: string }
                - { type: number }
                - { type: "null" }
            method: { type: string }
            params:
              type: object
              additionalProperties: true
        - type: array
          items:
            type: object
            additionalProperties: true

    McpJsonRpcResponse:
      oneOf:
        - type: object
          required: [jsonrpc, id, result]
          properties:
            jsonrpc: { type: string, const: "2.0" }
            id:
              oneOf:
                - { type: string }
                - { type: number }
                - { type: "null" }
            result: {}
        - type: object
          required: [jsonrpc, id, error]
          properties:
            jsonrpc: { type: string, const: "2.0" }
            id:
              oneOf:
                - { type: string }
                - { type: number }
                - { type: "null" }
            error:
              type: object
              required: [code, message]
              properties:
                code: { type: integer }
                message: { type: string }
                data: {}
        - type: array
          items:
            type: object
            additionalProperties: true

    HeadlessUrls:
      type: object
      required: [verify, badgeSvg]
      properties:
        verify: { type: string, nullable: true }
        badgeSvg: { type: string, nullable: true }

    HeadlessShareOfVoice:
      type: object
      required: [name, sharePct, isBrand]
      properties:
        name: { type: string }
        sharePct: { type: number }
        isBrand: { type: boolean }

    HeadlessPublicScan:
      type: object
      required:
        - token
        - domain
        - brandName
        - scanDate
        - totalEngines
        - citingEngines
        - visibilityScore
        - engineScores
        - shareOfVoice
        - urls
      properties:
        token: { type: string }
        domain: { type: string }
        brandName: { type: string }
        scanDate: { type: string, format: date-time }
        totalEngines: { type: integer }
        citingEngines:
          type: array
          items: { $ref: "#/components/schemas/Engine" }
        visibilityScore: { type: number, nullable: true }
        engineScores:
          type: array
          items: { $ref: "#/components/schemas/EngineScore" }
        shareOfVoice:
          type: array
          items: { $ref: "#/components/schemas/HeadlessShareOfVoice" }
        urls: { $ref: "#/components/schemas/HeadlessUrls" }

    HeadlessCompetitor:
      type: object
      required: [name, domain]
      properties:
        name: { type: string }
        domain: { type: string, nullable: true }

    HeadlessMissingPrompt:
      type: object
      required: [prompt, promptFit, missingEngines, citingEngines]
      properties:
        prompt: { type: string }
        promptFit:
          type: string
          enum: [core, adjacent, off_category]
          description: Core prompts affect the main score, adjacent prompts are opportunities, and off-category prompts are context.
        missingEngines:
          type: array
          items: { $ref: "#/components/schemas/Engine" }
        citingEngines:
          type: array
          items: { $ref: "#/components/schemas/Engine" }

    NumberMovement:
      type: object
      required: [before, after, delta]
      properties:
        before: { type: number, nullable: true }
        after: { type: number, nullable: true }
        delta: { type: number, nullable: true }

    HeadlessEngineMovement:
      allOf:
        - $ref: "#/components/schemas/NumberMovement"
        - type: object
          required: [engine]
          properties:
            engine: { $ref: "#/components/schemas/Engine" }

    HeadlessRescanComparison:
      type: object
      required:
        - baselineAuditId
        - rescanAuditId
        - visibilityScore
        - engineMentionRates
        - brandShareOfVoice
      properties:
        baselineAuditId: { type: string }
        rescanAuditId: { type: string }
        visibilityScore: { $ref: "#/components/schemas/NumberMovement" }
        engineMentionRates:
          type: array
          items: { $ref: "#/components/schemas/HeadlessEngineMovement" }
        brandShareOfVoice: { $ref: "#/components/schemas/NumberMovement" }

    HeadlessScoreBasis:
      type: object
      required: [description, promptFitWeights]
      properties:
        description: { type: string }
        promptFitWeights:
          type: object
          required: [core, adjacent, offCategory]
          properties:
            core: { type: number }
            adjacent: { type: number }
            offCategory: { type: number }

    HeadlessRepairLoopAction:
      type: object
      required: [label, description]
      properties:
        label: { type: string }
        description: { type: string }
        tool: { type: string }

    HeadlessRepairLoop:
      type: object
      required: [currentStep, summary, nextActions]
      properties:
        currentStep:
          type: string
          enum:
            - review_report
            - apply_fixes
            - publish_and_wait
            - start_rescan
            - compare_rescan
        summary: { type: string }
        nextActions:
          type: array
          items: { $ref: "#/components/schemas/HeadlessRepairLoopAction" }

    HeadlessOwnedFix:
      type: object
      required: [id, priority, title, body, fixPrompts, status, createdAt]
      properties:
        id: { type: string }
        priority: { type: string }
        title: { type: string }
        body: { type: string }
        fixPrompts:
          type: array
          items: { $ref: "#/components/schemas/FixPrompt" }
        status: { type: string }
        createdAt: { type: string, format: date-time }

    HeadlessOwnedReport:
      type: object
      required:
        - auditId
        - status
        - domain
        - brandName
        - category
        - scanDate
        - visibilityScore
        - engineScores
        - shareOfVoice
        - competitors
        - missingPrompts
        - fixes
        - scoreBasis
        - repairLoop
      properties:
        auditId: { type: string }
        status: { type: string }
        domain: { type: string }
        brandName: { type: string }
        category: { type: string, nullable: true }
        scanDate: { type: string, format: date-time }
        visibilityScore: { type: number, nullable: true }
        engineScores:
          type: array
          items: { $ref: "#/components/schemas/EngineScore" }
        shareOfVoice:
          type: array
          items: { $ref: "#/components/schemas/HeadlessShareOfVoice" }
        competitors:
          type: array
          items: { $ref: "#/components/schemas/HeadlessCompetitor" }
        missingPrompts:
          type: array
          items: { $ref: "#/components/schemas/HeadlessMissingPrompt" }
        fixes:
          type: array
          items: { $ref: "#/components/schemas/HeadlessOwnedFix" }
        scoreBasis: { $ref: "#/components/schemas/HeadlessScoreBasis" }
        repairLoop: { $ref: "#/components/schemas/HeadlessRepairLoop" }
        rescanComparison:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/HeadlessRescanComparison"

    HeadlessFixesResponse:
      type: object
      required: [auditId, fixes]
      properties:
        auditId: { type: string }
        fixes:
          type: array
          items: { $ref: "#/components/schemas/HeadlessOwnedFix" }

    CreateAuditBody:
      type: object
      required: [domain]
      properties:
        domain: { type: string, description: "e.g. acme.com" }
        brandName: { type: string }
        category:
          type: string
          description: "Industry/category, e.g. project management software"
        competitors:
          type: array
          items: { type: string }
        captchaToken:
          type: string
          description: Cloudflare Turnstile token when TURNSTILE_SECRET_KEY is configured.

    SurfaceCheckBody:
      type: object
      required: [url]
      properties:
        url:
          type: string
          description: Public homepage or product URL to inspect.

    SurfaceSignalStatus:
      type: string
      enum: [pass, warn, fail]

    SurfaceVerdict:
      type: string
      enum: [well_prepared, needs_cleanup, blocked]

    SurfaceSignal:
      type: object
      required: [key, label, status, detail]
      properties:
        key: { type: string }
        label: { type: string }
        status: { $ref: "#/components/schemas/SurfaceSignalStatus" }
        detail: { type: string }
        evidence: { type: string }

    SurfaceCheck:
      type: object
      required:
        - inputUrl
        - finalUrl
        - domain
        - checkoutDomain
        - score
        - verdict
        - summary
        - signals
        - nextSteps
        - scannedAt
      properties:
        inputUrl: { type: string }
        finalUrl: { type: string, nullable: true }
        domain: { type: string }
        checkoutDomain:
          type: string
          description: Normalized domain safe to send to paid checkout.
        score:
          type: number
          description: 0-100 surface readiness score; not an AI visibility score.
        verdict: { $ref: "#/components/schemas/SurfaceVerdict" }
        summary: { type: string }
        signals:
          type: array
          items: { $ref: "#/components/schemas/SurfaceSignal" }
        nextSteps:
          type: array
          items: { type: string }
        scannedAt: { type: string, format: date-time }

    AuditCheckoutSession:
      type: object
      required: [url, auditId, legal]
      properties:
        url: { type: string, format: uri }
        auditId: { type: string }
        legal: { $ref: "#/components/schemas/LegalNotice" }

    AuditRescanSession:
      type: object
      required: [url, auditId, checkoutRequired, rescanKind]
      properties:
        url: { type: string }
        auditId: { type: string }
        checkoutRequired: { type: boolean }
        rescanKind: { type: string, enum: [included, paid] }
        status: { type: string, enum: [started, checkout_required] }
        message: { type: string }
        reportUrl: { type: string }
        checkoutUrl: { type: string }
        amountCents: { type: integer }
        currency: { type: string }
        productName: { type: string }
        legal: { $ref: "#/components/schemas/LegalNotice" }

    RescanKind:
      type: string
      enum: [initial, included, paid]

    Engine:
      type: string
      enum:
        - openai
        - anthropic
        - gemini
        - xai
        - copilot
        - perplexity
        - ai_overviews

    Sentiment:
      type: string
      enum: [positive, neutral, negative]

    CitationSource:
      type: string
      enum: [grounding, structured, text]

    Citation:
      type: object
      required: [url]
      properties:
        url: { type: string }
        title: { type: string }
        source: { $ref: "#/components/schemas/CitationSource" }

    CompetitorMention:
      type: object
      required: [name, mentioned]
      properties:
        name: { type: string }
        mentioned: { type: boolean }
        rank: { type: integer, nullable: true }

    AuditResult:
      type: object
      required:
        - engine
        - prompt
        - brandMentioned
        - competitorMentions
        - citations
        - responseExcerpt
      properties:
        engine: { $ref: "#/components/schemas/Engine" }
        prompt: { type: string }
        promptFit:
          type: string
          enum: [core, adjacent, off_category]
          description: Core prompts affect the main score, adjacent prompts are opportunities, and off-category prompts are context.
        brandMentioned: { type: boolean }
        brandRank: { type: integer, nullable: true }
        sentiment: { $ref: "#/components/schemas/Sentiment", nullable: true }
        competitorMentions:
          type: array
          items: { $ref: "#/components/schemas/CompetitorMention" }
        citations:
          type: array
          items: { $ref: "#/components/schemas/Citation" }
        responseExcerpt: { type: string }

    EngineScore:
      type: object
      required: [engine, mentionRate, avgRank]
      properties:
        engine: { $ref: "#/components/schemas/Engine" }
        mentionRate: { type: number }
        avgRank: { type: number, nullable: true }

    ShareOfVoice:
      type: object
      required: [name, isBrand, mentions, sharePct]
      properties:
        name: { type: string }
        isBrand: { type: boolean }
        mentions: { type: number }
        sharePct: { type: number }

    FixPrompt:
      type: object
      required: [label, content]
      properties:
        label: { type: string }
        content: { type: string }
        language: { type: string }

    Recommendation:
      type: object
      required: [id, title, body, priority, status, createdAt]
      properties:
        id: { type: string }
        auditId: { type: string, nullable: true }
        priority: { type: string, enum: [high, medium, low] }
        title: { type: string }
        body: { type: string }
        fixPrompts:
          type: array
          items: { $ref: "#/components/schemas/FixPrompt" }
        status: { type: string, enum: [open, done, dismissed] }
        createdAt: { type: string, format: date-time }

    Audit:
      type: object
      required:
        - id
        - status
        - domain
        - brandName
        - rescanKind
        - includedRescanAvailable
        - competitors
        - prompts
        - results
        - createdAt
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, running, completed, failed] }
        domain: { type: string }
        brandName: { type: string }
        category: { type: string }
        parentAuditId: { type: string, nullable: true }
        rescanKind: { $ref: "#/components/schemas/RescanKind" }
        includedRescanAuditId: { type: string, nullable: true }
        includedRescanAvailable: { type: boolean }
        includedRescanExpiresAt:
          { type: string, format: date-time, nullable: true }
        competitors:
          type: array
          items: { type: string }
        competitorDomains:
          type: array
          items: { type: string }
        prompts:
          type: array
          items: { type: string }
        results:
          type: array
          items: { $ref: "#/components/schemas/AuditResult" }
        visibilityScore:
          type: number
          nullable: true
          description: "0-100 overall mention rate"
        engineScores:
          type: array
          items: { $ref: "#/components/schemas/EngineScore" }
        shareOfVoice:
          type: array
          items: { $ref: "#/components/schemas/ShareOfVoice" }
        rescanComparison:
          nullable: true
          allOf:
            - $ref: "#/components/schemas/HeadlessRescanComparison"
        recommendations:
          type: array
          items: { $ref: "#/components/schemas/Recommendation" }
        createdAt: { type: string, format: date-time }
        completedAt: { type: string, format: date-time, nullable: true }
        error: { type: string, nullable: true }

    PageViewEvent:
      type: object
      required: [path]
      properties:
        path: { type: string }
        referrer: { type: string }

    FunnelEvent:
      type: object
      required: [name]
      properties:
        name: { type: string }
        source: { type: string }
        path: { type: string }
        sessionId: { type: string }
        params:
          type: object
          additionalProperties: true
