openapi: 3.1.0
info:
  title: Novera API
  version: 1.0.0
  description: Invisible bot risk assessments with server-side verification.
servers:
  - url: https://api.novera.dev
paths:
  /v1/assessments:
    post:
      summary: Create a browser assessment
      description: Returns an opaque token only. Never returns a risk score.
      security: []
      parameters:
        - in: header
          name: X-Site-Key
          required: true
          schema: { type: string, pattern: '^site_(test|live)_' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssessmentRequest'
      responses:
        '201':
          description: Assessment token issued
          headers:
            RateLimit-Limit: { schema: { type: integer } }
            RateLimit-Remaining: { schema: { type: integer } }
            RateLimit-Reset: { schema: { type: integer } }
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [token, expires_in, token_type]
                properties:
                  token: { type: string, pattern: '^cap_' }
                  expires_in: { type: integer, minimum: 0, maximum: 300 }
                  token_type: { const: single_use }
        '202':
          description: A short-lived managed challenge must be completed before a token is issued. The official SDK handles this response automatically.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [challenge]
                properties:
                  challenge: { $ref: '#/components/schemas/ManagedChallenge' }
        '403': { $ref: '#/components/responses/DomainNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/challenges:
    post:
      summary: Complete a managed computational challenge
      description: Browser endpoint used automatically by the SDK. The challenge is bound to the site key, origin, trusted IP, signed device, and edge fingerprint, and is single-use.
      security: []
      parameters:
        - in: header
          name: X-Site-Key
          required: true
          schema: { type: string, pattern: '^site_(test|live)_' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [site_key, challenge_id, solution]
              properties:
                site_key: { type: string, pattern: '^site_(test|live)_' }
                challenge_id: { type: string, pattern: '^chl_' }
                solution: { type: string, pattern: '^\d{1,12}$' }
      responses:
        '201':
          description: Challenge accepted and assessment token issued
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AssessmentToken' }
        '400': { $ref: '#/components/responses/TokenError' }
        '403': { $ref: '#/components/responses/DomainNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/siteverify:
    post:
      summary: Verify and consume an assessment token
      description: Server-to-server only. Returns the stored score after authenticating and atomically consuming the token.
      security:
        - SecretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [token, remote_ip]
              properties:
                token: { type: string, pattern: '^cap_' }
                expected_action: { type: string, pattern: '^[a-z][a-z0-9_.:-]{0,63}$' }
                remote_ip: { type: string, maxLength: 64, description: Original browser IP as observed by the customer backend. Production verification rejects missing or mismatched values. }
      responses:
        '200':
          description: Verified server-side risk result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VerificationResult' }
        '400': { $ref: '#/components/responses/TokenError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { $ref: '#/components/responses/Replay' }
        '429': { $ref: '#/components/responses/RateLimited' }
components:
  securitySchemes:
    SecretKey:
      type: http
      scheme: bearer
      bearerFormat: secret_live_…
  schemas:
    AssessmentToken:
      type: object
      additionalProperties: false
      required: [token, expires_in, token_type]
      properties:
        token: { type: string, pattern: '^cap_' }
        expires_in: { type: integer, minimum: 0, maximum: 300 }
        token_type: { const: single_use }
    ManagedChallenge:
      type: object
      additionalProperties: false
      required: [id, algorithm, nonce, difficulty, expires_in, verify_url]
      properties:
        id: { type: string, pattern: '^chl_' }
        algorithm: { const: SHA-256 }
        nonce: { type: string }
        difficulty: { type: integer, minimum: 12, maximum: 20 }
        expires_in: { type: integer, maximum: 60 }
        verify_url: { const: /v1/challenges }
    AssessmentRequest:
      type: object
      additionalProperties: false
      required: [site_key, action, signals]
      properties:
        site_key: { type: string, pattern: '^site_(test|live)_' }
        action: { type: string, pattern: '^[a-z][a-z0-9_.:-]{0,63}$' }
        signals:
          type: object
          additionalProperties: false
          properties:
            dwellMs: { type: number, minimum: 0, maximum: 600000 }
            pointerCount: { type: number, minimum: 0, maximum: 10000 }
            pointerDistance: { type: number, minimum: 0, maximum: 10000000 }
            scrollCount: { type: number, minimum: 0, maximum: 10000 }
            focusChanges: { type: number, minimum: 0, maximum: 1000 }
            visibilityChanges: { type: number, minimum: 0, maximum: 1000 }
            touchCount: { type: number, minimum: 0, maximum: 10000 }
            keyTimingCount: { type: number, minimum: 0, maximum: 10000 }
            webdriver: { type: boolean }
            headlessHint: { type: boolean }
            timezonePresent: { type: boolean }
            screenConsistent: { type: boolean }
            cookiesEnabled: { type: boolean }
            storageAvailable: { type: boolean }
    VerificationResult:
      type: object
      additionalProperties: false
      required: [success, score, risk_level, action, recommended_action, reasons, created_at, hostname, assessment_id, latency_ms]
      properties:
        success: { const: true }
        score: { type: number, minimum: 0, maximum: 1 }
        risk_level: { enum: [critical, high, medium, low, trusted] }
        action: { type: string }
        recommended_action: { enum: [allow, monitor, rate_limit, challenge, mfa, block] }
        reasons: { type: array, items: { type: string } }
        created_at: { type: string, format: date-time }
        hostname: { type: string }
        assessment_id: { type: string }
        latency_ms: { type: integer }
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            request_id: { type: [string, 'null'] }
  responses:
    DomainNotAllowed: { description: Hostname is not configured, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    RateLimited: { description: Rate limit exceeded, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    TokenError: { description: Token invalid, expired, or action mismatch, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Unauthorized: { description: Secret invalid or missing, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    Replay: { description: Token already used, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
