openapi: 3.1.0
info:
  title: Tickory API Key Auth Surface
  version: 0.1.0
  description: >
    API key auth for agents and bots. Use the X-API-Key header. Scope requirements
    are described per operation with x-tickory-required-scopes.
servers:
  - url: http://localhost:8080
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    apiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: User-scoped API key for server-to-server integrations.
  schemas:
    ErrorResponse:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
        message:
          type: string
        code:
          type: string
        details:
          type: object
          additionalProperties: true
    ScanMatch:
      type: object
      required: [symbol, exchange, contract_type, timestamp, price, volume_quote, rsi_14, ma_50]
      properties:
        symbol:
          type: string
        exchange:
          type: string
        contract_type:
          type: string
        timestamp:
          type: string
          format: date-time
        price:
          type: number
        volume_quote:
          type: number
        rsi_14:
          type: number
        ma_50:
          type: number
        ma_200:
          type: number
        scan_expression:
          type: string
        chart_url:
          type: string
        matched_metrics:
          type: object
          additionalProperties: true
        timeframe:
          type: string
    ExecuteScanResponse:
      type: object
      required: [run_id, scan_id, started_at, status, matches]
      properties:
        run_id:
          type: string
        scan_id:
          type: string
        started_at:
          type: string
          format: date-time
        status:
          type: string
        matches:
          type: array
          items:
            $ref: '#/components/schemas/ScanMatch'
      example:
        run_id: "22222222-2222-2222-2222-222222222222"
        scan_id: "crypto_oversold"
        started_at: "2026-03-12T12:00:00Z"
        status: "completed"
        matches:
          - symbol: "BTCUSDT"
            exchange: "binance"
            contract_type: "perp"
            timestamp: "2026-03-12T12:00:00Z"
            price: 50000
            volume_quote: 1500000
            rsi_14: 25
            ma_50: 49000
            timeframe: "1m"
    AlertEventDeliverySummary:
      type: object
      required: [channel, status, attempts]
      properties:
        channel:
          type: string
        status:
          type: string
        attempts:
          type: integer
        error_message:
          type: string
          nullable: true
        sent_at:
          type: string
          format: date-time
          nullable: true
    AlertEventFreshness:
      type: object
      required: [event_created_at]
      properties:
        source_timestamp:
          type: string
          format: date-time
          nullable: true
        event_created_at:
          type: string
          format: date-time
        lag_seconds:
          type: integer
          nullable: true
    AlertEventHardGateSummary:
      type: object
      required: [configured, result]
      properties:
        configured:
          type: object
          additionalProperties: true
        result:
          type: string
    AlertEventCELSummary:
      type: object
      required: [result]
      properties:
        expression:
          type: string
        result:
          type: string
    AlertEventEvidence:
      type: object
      required: [indicator_values, hard_gate_summary, cel_summary, data_freshness]
      properties:
        indicator_values:
          type: object
          additionalProperties: true
        hard_gate_summary:
          $ref: '#/components/schemas/AlertEventHardGateSummary'
        cel_summary:
          $ref: '#/components/schemas/AlertEventCELSummary'
        data_freshness:
          $ref: '#/components/schemas/AlertEventFreshness'
    AlertEvent:
      type: object
      required:
        - payload_version
        - alert_event_id
        - event_type
        - scan_id
        - created_at
        - status
        - evidence
        - delivery_summary
      properties:
        payload_version:
          type: string
        alert_event_id:
          type: string
        event_type:
          type: string
        scan_id:
          type: string
        run_id:
          type: string
          nullable: true
        symbol:
          type: string
          nullable: true
        timeframe:
          type: string
        created_at:
          type: string
          format: date-time
        status:
          type: string
        evidence:
          $ref: '#/components/schemas/AlertEventEvidence'
        delivery_summary:
          type: array
          items:
            $ref: '#/components/schemas/AlertEventDeliverySummary'
        suppression_reason_codes:
          type: array
          items:
            type: string
    ListAlertEventsResponse:
      type: object
      required: [payload_version, events, count]
      properties:
        payload_version:
          type: string
        events:
          type: array
          items:
            $ref: '#/components/schemas/AlertEvent'
        count:
          type: integer
        next_cursor:
          type: string
          nullable: true
    GetAlertEventResponse:
      type: object
      required: [payload_version, event]
      properties:
        payload_version:
          type: string
        event:
          $ref: '#/components/schemas/AlertEvent'
    MarketDataSnapshot:
      type: object
      required: [symbol, exchange, contract_type, timeframe, timestamp, price]
      properties:
        symbol:
          type: string
        exchange:
          type: string
          description: Exchange identifier for the cached Tickory snapshot.
        contract_type:
          type: string
        timeframe:
          type: string
        timestamp:
          type: string
          format: date-time
        price:
          type: number
        rsi_14:
          type: number
          nullable: true
        ma_50:
          type: number
          nullable: true
        ma_200:
          type: number
          nullable: true
        volume_quote:
          type: number
          nullable: true
        funding_rate:
          type: number
          nullable: true
        daily_move_pct:
          type: number
          nullable: true
    MarketDataListResponse:
      type: object
      required: [market_data, count]
      properties:
        market_data:
          type: array
          items:
            $ref: '#/components/schemas/MarketDataSnapshot'
        count:
          type: integer
    ExplainGateResult:
      type: object
      required: [gate, reason_code, summary]
      properties:
        gate:
          type: string
        reason_code:
          type: string
        summary:
          type: string
        configured_value:
          nullable: true
        observed_value:
          nullable: true
        count:
          type: integer
          nullable: true
    ExplainCELEvaluation:
      type: object
      required: [result, reason_code, summary]
      properties:
        expression:
          type: string
        result:
          type: string
        reason_code:
          type: string
        summary:
          type: string
    ExplainMetric:
      type: object
      required: [rank, name, value, source]
      properties:
        rank:
          type: integer
        name:
          type: string
        value:
          nullable: true
        source:
          type: string
        captured_at:
          type: string
          format: date-time
          nullable: true
    ExplainTimestamps:
      type: object
      properties:
        source_timestamp:
          type: string
          format: date-time
          nullable: true
        event_created_at:
          type: string
          format: date-time
          nullable: true
        run_started_at:
          type: string
          format: date-time
          nullable: true
        reset_at:
          type: string
          format: date-time
          nullable: true
        lag_seconds:
          type: integer
          nullable: true
    ExplainSuppressionContext:
      type: object
      required: [suppressed, reason_codes]
      properties:
        suppressed:
          type: boolean
        reason_codes:
          type: array
          items:
            type: string
        suppressed_count:
          type: integer
          nullable: true
        daily_limit:
          type: integer
          nullable: true
        sent_today:
          type: integer
          nullable: true
        reset_at:
          type: string
          format: date-time
          nullable: true
    ExplainResponseBody:
      type: object
      required: [gates_passed, gates_failed, cel_evaluation, top_contributing_metrics, timestamps]
      properties:
        gates_passed:
          type: array
          items:
            $ref: '#/components/schemas/ExplainGateResult'
        gates_failed:
          type: array
          items:
            $ref: '#/components/schemas/ExplainGateResult'
        cel_evaluation:
          $ref: '#/components/schemas/ExplainCELEvaluation'
        top_contributing_metrics:
          type: array
          items:
            $ref: '#/components/schemas/ExplainMetric'
        timestamps:
          $ref: '#/components/schemas/ExplainTimestamps'
        suppression:
          $ref: '#/components/schemas/ExplainSuppressionContext'
    AlertEventExplainResponse:
      type: object
      required: [payload_version, alert_event_id, scan_id, event_type, explanation]
      properties:
        payload_version:
          type: string
        alert_event_id:
          type: string
        scan_id:
          type: string
        run_id:
          type: string
          nullable: true
        event_type:
          type: string
        symbol:
          type: string
          nullable: true
        timeframe:
          type: string
        explanation:
          $ref: '#/components/schemas/ExplainResponseBody'
      example:
        payload_version: "1.0"
        alert_event_id: "11111111-1111-1111-1111-111111111111"
        scan_id: "crypto_oversold"
        run_id: "22222222-2222-2222-2222-222222222222"
        event_type: "throttle_summary"
        timeframe: "15m"
        explanation:
          gates_passed:
            - gate: "min_volume_quote"
              reason_code: "volume_gate_satisfied"
              summary: "configured minimum volume gate passed"
              configured_value: 1000000
              observed_value: 1500000
          gates_failed: []
          cel_evaluation:
            expression: "rsi_14 < 30"
            result: "suppressed"
            reason_code: "daily_limit_reached"
            summary: "stored run evidence matched, but delivery was suppressed by the daily alert limit"
          top_contributing_metrics:
            - rank: 1
              name: "suppressed_count"
              value: 5
              source: "payload"
              captured_at: "2026-03-10T14:04:30Z"
            - rank: 2
              name: "daily_limit"
              value: 20
              source: "payload"
              captured_at: "2026-03-10T14:04:30Z"
          timestamps:
            source_timestamp: "2026-03-10T14:04:30Z"
            event_created_at: "2026-03-10T14:05:00Z"
            reset_at: "2026-03-11T00:00:00Z"
            lag_seconds: 30
          suppression:
            suppressed: true
            reason_codes: ["daily_limit"]
            suppressed_count: 5
            daily_limit: 20
            sent_today: 20
            reset_at: "2026-03-11T00:00:00Z"
    ScanRunExplainNoMatchResponse:
      type: object
      required: [payload_version, run_id, scan_id, scan_name, symbols_scanned, symbols_matched, started_at, explanation]
      properties:
        payload_version:
          type: string
        run_id:
          type: string
        scan_id:
          type: string
        scan_name:
          type: string
        timeframe:
          type: string
        symbols_scanned:
          type: integer
        symbols_matched:
          type: integer
        started_at:
          type: string
          format: date-time
        explanation:
          $ref: '#/components/schemas/ExplainResponseBody'
      example:
        payload_version: "1.0"
        run_id: "33333333-3333-3333-3333-333333333333"
        scan_id: "scan-user-1"
        scan_name: "Low RSI Scan"
        timeframe: "15m"
        symbols_scanned: 24
        symbols_matched: 0
        started_at: "2026-03-10T12:00:00Z"
        explanation:
          gates_passed:
            - gate: "require_rsi_14"
              reason_code: "rsi_14_ready"
              summary: "required RSI-14 data recorded no readiness failures"
              configured_value: true
              observed_value: true
          gates_failed:
            - gate: "cel_expression"
              reason_code: "cel_expression_not_matched"
              summary: "symbols passed hard gates but evaluated false for the CEL expression"
              count: 12
            - gate: "min_volume_quote"
              reason_code: "volume_gate_rejected"
              summary: "symbols were rejected by the configured volume gate"
              configured_value: 1000000
              count: 4
          cel_evaluation:
            expression: "rsi_14 < 30"
            result: "no_match"
            reason_code: "cel_expression_not_matched"
            summary: "Most symbols cleared liquidity but failed the CEL expression."
          top_contributing_metrics:
            - rank: 1
              name: "cel_expression"
              value: 12
              source: "rejection_stats"
              captured_at: "2026-03-10T12:00:00Z"
            - rank: 2
              name: "min_volume_quote"
              value: 4
              source: "rejection_stats"
              captured_at: "2026-03-10T12:00:00Z"
          timestamps:
            run_started_at: "2026-03-10T12:00:00Z"
paths:
  /api/auth/api-keys:
    get:
      summary: List API keys for the authenticated user
      security:
        - bearerAuth: []
    post:
      summary: Create a new API key
      security:
        - bearerAuth: []
  /api/auth/api-keys/{keyId}:
    delete:
      summary: Revoke an API key
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
  /api/auth/api-keys/{keyId}/rotate:
    post:
      summary: Rotate an API key and return a replacement secret once
      security:
        - bearerAuth: []
      parameters:
        - in: path
          name: keyId
          required: true
          schema:
            type: string
  /api/crypto/scans:
    get:
      summary: List scans
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    post:
      summary: Create a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/{scanId}:
    get:
      summary: Get a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    put:
      summary: Update a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    delete:
      summary: Delete a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/execute:
    post:
      summary: Execute a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
      responses:
        '200':
          description: Scan executed successfully and returned the immediate run payload
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExecuteScanResponse'
        '400':
          description: Invalid scan request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Authenticated user does not own the scan
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/crypto/scans/validate:
    post:
      summary: Validate a scan expression
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/variables:
    get:
      summary: List scan builder variables
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/runs:
    get:
      summary: List scan runs
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/crypto/scans/runs/{runId}:
    get:
      summary: Get a scan run
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/crypto/scans/runs/{runId}/explain-no-match:
    get:
      summary: Explain why a scan run produced no usable trigger
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
      parameters:
        - in: path
          name: runId
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Structured no-match explanation for agents
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanRunExplainNoMatchResponse'
        '400':
          description: Invalid run identifier
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Authenticated user does not own the scan run
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Scan run not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/crypto/scans/{scanId}/schedule:
    get:
      summary: Get a schedule
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    post:
      summary: Create a schedule
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    put:
      summary: Update a schedule
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    delete:
      summary: Delete a schedule
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/{scanId}/share:
    post:
      summary: Create a share link for a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    delete:
      summary: Revoke a share link for a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/{scanId}/alerts:
    get:
      summary: Get alert config
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    post:
      summary: Create alert config
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    put:
      summary: Update alert config
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
    delete:
      summary: Delete alert config
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/{scanId}/alerts/test:
    post:
      summary: Send a test alert
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_scans
  /api/crypto/scans/{scanId}/alerts/limits:
    get:
      summary: Get alert limit status
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/crypto/scans/{scanId}/alerts/rejections:
    get:
      summary: Get alert rejections for a scan
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/crypto/market-data:
    get:
      summary: Get current Tickory market indicator snapshots
      description: >
        Returns the latest cached Tickory snapshot for tracked symbols only. This is a
        narrow product endpoint for current indicator context, not a general exchange
        market-data API. It does not expose historical ranges, raw trades, order books,
        or arbitrary exchange coverage.
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
      parameters:
        - in: query
          name: symbols
          required: true
          schema:
            type: string
          description: Comma-separated tracked symbols, for example `BTCUSDT,ETHUSDT`.
        - in: query
          name: exchange
          schema:
            type: string
            default: binance
          description: Optional exchange filter. Only `binance` is supported today.
        - in: query
          name: contract_type
          schema:
            type: string
            default: perp
            enum: [perp, spot]
          description: Optional contract type filter for the cached snapshot lookup.
        - in: query
          name: timeframe
          schema:
            type: string
            default: 1m
            enum: [1m, 5m, 15m, 1h]
          description: Optional cached timeframe to read.
      responses:
        '200':
          description: Current market data snapshots
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MarketDataListResponse'
        '400':
          description: Invalid symbol or filter request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: No current snapshot available for the requested tracked symbols
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/crypto/alert-events:
    get:
      summary: List alert events
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
      parameters:
        - in: query
          name: since
          schema:
            type: string
            format: date-time
          description: Return events created at or after this RFC3339 timestamp.
        - in: query
          name: scan_id
          schema:
            type: string
          description: Filter events to a specific scan.
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - in: query
          name: cursor
          schema:
            type: string
          description: Opaque pagination cursor returned by the previous page.
      responses:
        '200':
          description: Alert event page
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ListAlertEventsResponse'
  /api/crypto/alert-events/{eventId}:
    get:
      summary: Get a single alert event
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
      parameters:
        - in: path
          name: eventId
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Alert event detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetAlertEventResponse'
  /api/crypto/alert-events/{eventId}/explain:
    get:
      summary: Explain why an alert event triggered or was suppressed
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
      parameters:
        - in: path
          name: eventId
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Structured alert-event explanation for agents
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AlertEventExplainResponse'
        '400':
          description: Invalid event identifier
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Missing or invalid credentials
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          description: Authenticated user cannot access this alert event
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Alert event not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /api/crypto/alerts/history:
    get:
      summary: List alert delivery history
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/crypto/alerts/rejections:
    get:
      summary: List alert rejections across scans
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/activity:
    get:
      summary: List activity feed entries
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/alert-sources:
    get:
      summary: List alert sources
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
    post:
      summary: Create an alert source
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
  /api/alert-sources/{sourceId}:
    get:
      summary: Get an alert source
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
    patch:
      summary: Update an alert source
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
    delete:
      summary: Delete an alert source
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
  /api/alert-sources/{sourceId}/routes:
    get:
      summary: List routes for an alert source
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
  /api/alert-sources/{sourceId}/activity:
    get:
      summary: List source activity
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - read_events
  /api/alert-sources/{sourceId}/test:
    post:
      summary: Send a test event through a source
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
  /api/alert-sources/{sourceId}/test-payload:
    post:
      summary: Preview routing with a test payload
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
  /api/alert-routes:
    post:
      summary: Create an alert route
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
  /api/alert-routes/{routeId}:
    delete:
      summary: Delete an alert route
      security:
        - bearerAuth: []
        - apiKeyAuth: []
      x-tickory-required-scopes:
        - manage_routing
