openapi: 3.1.0
info:
  title: Shypset API
  version: '1.0.0'
  summary: Run Shypset security scans from scripts, CI and AI agents.
  description: |
    Start a scan of a public URL, poll it, read the report and get fix prompts as JSON.
    Human-readable docs: https://www.shypset.com/docs/api

    A request acts as the account that owns the API key and uses that account's plan: the
    same scan quota, report and fix prompts as in the app. Works on every plan; on Free the
    report is shortened (tier "teaser") and only the most severe issue has a fix prompt.

    Server-side only: the API sends no CORS headers. Keep the key in an environment variable
    or a CI secret, never in client code.

    Rate limits (sliding 60-second window): reads 60/min per key and 120/min per account;
    starting scans 10/min per account. Every 429 carries Retry-After (seconds).
  contact:
    name: Shypset
    url: https://www.shypset.com
externalDocs:
  url: https://www.shypset.com/docs/api
servers:
  - url: https://dqyrveqnsggmckcabgmm.supabase.co/functions/v1/api-v1
security:
  - apiKey: []
tags:
  - name: scans

paths:
  /v1/scans:
    post:
      tags: [scans]
      operationId: createScan
      summary: Start a scan
      description: |
        Starts a scan in the background and answers at once. Counts against the plan's scan
        quota like a scan in the app. Poll `poll_url` (also the Location header).
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            1-100 printable ASCII characters (no spaces). For 24 hours a retry with the same
            key and the same body returns the same scan (header Idempotent-Replayed: true);
            the same key with another body is 409 idempotency_conflict.
          schema:
            type: string
            pattern: '^[\x21-\x7E]{1,100}$'
      requestBody:
        required: true
        description: JSON object, at most 16 KB.
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateScanRequest'
            example:
              url: https://example.com
      responses:
        '202':
          description: Scan accepted.
          headers:
            Location:
              description: Same as poll_url.
              schema: { type: string, format: uri }
            Idempotent-Replayed:
              description: '"true" when an earlier request with this Idempotency-Key started the scan.'
              schema: { type: string, enum: ['true'] }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanAccepted'
        '400':
          description: |
            invalid_json | invalid_url | invalid_project | tos_required | invalid_idempotency_key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/InvalidToken' }
        '403':
          description: account_blocked | domain_opted_out
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '409':
          description: |
            idempotency_conflict (same key, different body) | idempotency_in_progress
            (the first request is still running; Retry-After: 2)
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '413':
          description: body_too_large (over 16 KB)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          description: |
            rate_limited (per-minute API limit), or the plan's scan quota: free_monthly |
            pro_monthly | pro_daily | hostname_hourly | site_hourly_global (fields limit,
            reset_at, on Free also upgrade_url).
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503':
          description: |
            scanner_paused (Retry-After: 600) | auth_unavailable | rate_limit_unavailable |
            profile_unavailable | idempotency_unavailable | plan_unavailable |
            quota_unavailable | project_unavailable | blocklist_unavailable. Fails closed: no
            scan was started.
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    get:
      tags: [scans]
      operationId: listScans
      summary: List your scans
      description: |
        Newest first: the caller's scans and the scans in the caller's projects. A page may
        hold fewer than `limit` items even when more exist: follow `next_cursor` until it is
        null.
      parameters:
        - name: url
          in: query
          description: Only scans of this hostname (example.com or a full URL).
          schema: { type: string, maxLength: 2048 }
        - name: project_id
          in: query
          description: Only scans of one of your projects.
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
        - name: cursor
          in: query
          description: next_cursor of the previous page (opaque).
          schema: { type: string }
      responses:
        '200':
          description: A page of scans.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScanList' }
        '400':
          description: invalid_url | invalid_project | invalid_limit | invalid_cursor
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/InvalidToken' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/scans/{id}:
    get:
      tags: [scans]
      operationId: getScan
      summary: Get a scan (progress or report)
      description: |
        While the scan runs: progress, with Retry-After: 3 (poll every 3-5 s). When done:
        status completed or failed, the report as the app shows it to this account, and for a
        completed scan previous_scan_id and diff (null without a previous completed scan of
        the same hostname). Someone else's scan is 404, never 403.
      parameters:
        - $ref: '#/components/parameters/ScanId'
      responses:
        '200':
          description: Progress or the finished scan.
          headers:
            Retry-After:
              description: '3 while the scan runs.'
              schema: { type: integer }
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ScanProgress'
                  - $ref: '#/components/schemas/ScanDone'
        '401': { $ref: '#/components/responses/InvalidToken' }
        '404': { $ref: '#/components/responses/NotFound' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/scans/{id}/fix-prompt:
    get:
      tags: [scans]
      operationId: getFixPrompt
      summary: Get a fix prompt
      description: |
        A prompt for an AI coding tool. Without finding_id: the open issues of the scan, at
        most the 15 most severe (truncated = true when cut). With finding_id: that finding.
        Free: only the most severe issue has a prompt; the others are 403 upgrade_required.
      parameters:
        - $ref: '#/components/parameters/ScanId'
        - name: target
          in: query
          description: Tool the prompt is written for. Default - the platform detected on the site.
          schema: { $ref: '#/components/schemas/FixTarget' }
        - name: finding_id
          in: query
          description: id of one finding from report.findings.
          schema: { type: string, pattern: '^[\x21-\x7E]{1,100}$' }
      responses:
        '200':
          description: The prompt.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FixPrompt' }
        '400':
          description: invalid_target | invalid_finding_id
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401': { $ref: '#/components/responses/InvalidToken' }
        '403':
          description: upgrade_required (field upgrade_url)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: not_found (no such scan or not yours) | finding_not_found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '405': { $ref: '#/components/responses/MethodNotAllowed' }
        '409':
          description: |
            scan_not_ready (field status; Retry-After: 3 while it runs) | nothing_to_fix
          headers:
            Retry-After: { $ref: '#/components/headers/RetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/Unavailable' }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        Personal API key from Settings -> API keys, sent as
        "Authorization: Bearer shp_live_<your key>". Up to 5 active keys per account; each key
        expires after 7, 30, 90 or 365 days, or never, as chosen at creation. Revoked and
        expired keys get 401 invalid_token.

  parameters:
    ScanId:
      name: id
      in: path
      required: true
      description: scan_id from POST /v1/scans.
      schema: { type: string, format: uuid }

  headers:
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, minimum: 1 }

  responses:
    InvalidToken:
      description: |
        invalid_token - the key is missing, malformed, unknown, revoked or expired.
      headers:
        WWW-Authenticate:
          schema: { type: string, enum: [Bearer] }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: not_found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    MethodNotAllowed:
      description: method_not_allowed; the Allow header lists the methods of this path.
      headers:
        Allow:
          schema: { type: string }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: |
        rate_limited - fields limit, retry_after_seconds, scope (token_read_minute |
        user_read_minute | user_scan_minute).
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: internal_error
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unavailable:
      description: auth_unavailable | rate_limit_unavailable (Retry-After 5)
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Severity:
      type: string
      enum: [critical, high, medium, low, info]

    FixTarget:
      type: string
      enum: [lovable, bolt, v0, replit, cursor, windsurf, claude, generic]

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          additionalProperties: true
          properties:
            code: { type: string, description: Stable machine-readable code. }
            message: { type: string }
            limit: { type: integer }
            retry_after_seconds: { type: integer }
            scope: { type: string }
            reset_at: { type: string, format: date-time }
            upgrade_url: { type: string, format: uri }
            status: { type: string, description: 'scan_not_ready: the scan status.' }

    CreateScanRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          description: Public http(s) URL on port 80 or 443.
        project_id:
          type: string
          format: uuid
          description: One of your projects; its domain must match the URL.

    ScanAccepted:
      type: object
      required: [scan_id, status, poll_url]
      properties:
        scan_id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, scanning, completed, failed]
          description: |
            A new scan is queued or scanning. An idempotent replay (Idempotent-Replayed: true)
            returns the current status of the scan it started, which can be completed or failed.
        poll_url: { type: string, format: uri }

    ScanProgress:
      type: object
      required: [id, url, status, queue_position, progress]
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        status: { type: string, description: queued or scanning. }
        queue_position:
          type: [integer, 'null']
          description: 1-based place in the queue while queued, else null.
        progress:
          type: object
          properties:
            modules_completed: { type: integer }
            modules_total: { type: integer }
            module_status:
              type: object
              additionalProperties: { type: string }

    ScanDone:
      type: object
      required: [id, url, status, tier, report]
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        status: { type: string, enum: [completed, failed] }
        tier:
          type: string
          enum: [full, teaser]
          description: full - Pro or a Launch Pass for this domain; teaser - Free (shortened report).
        report: { $ref: '#/components/schemas/Report' }
        previous_scan_id:
          type: [string, 'null']
          format: uuid
          description: Completed scans only.
        diff:
          oneOf:
            - $ref: '#/components/schemas/ScanDiff'
            - type: 'null'
          description: Completed scans only; null without a previous completed scan.

    ScanDiff:
      type: object
      description: Since previous_scan_id, over the findings this account's plan shows.
      required: [new, fixed, still_open, new_by_severity]
      properties:
        new: { type: integer }
        fixed: { type: integer }
        still_open: { type: integer }
        new_by_severity:
          type: object
          required: [critical, high, medium, low, info]
          properties:
            critical: { type: integer }
            high: { type: integer }
            medium: { type: integer }
            low: { type: integer }
            info: { type: integer }

    Report:
      type: object
      description: |
        The report as the app shows it to this account. Teaser (Free): the 5 most severe
        findings with evidence and remediation empty, descriptions up to 240 characters, and
        the detailed sections null. More sections than listed here may be present.
      additionalProperties: true
      properties:
        scan_id: { type: string, format: uuid }
        url: { type: string }
        status: { type: string, enum: [completed, failed] }
        error_message: { type: [string, 'null'] }
        risk_score: { type: number, description: '0-100, higher is safer.' }
        total_findings: { type: integer }
        verified_checks_available:
          type: integer
          description: Deeper checks that did not run because the domain is not verified.
        created_at: { type: [string, 'null'], format: date-time }
        counts:
          type: object
          properties:
            critical: { type: integer }
            high: { type: integer }
            medium: { type: integer }
            low: { type: integer }
            info: { type: integer }
        findings:
          type: array
          description: Sorted by severity, then id. Evidence is masked.
          items: { $ref: '#/components/schemas/Finding' }

    Finding:
      type: object
      properties:
        id: { type: string }
        severity: { $ref: '#/components/schemas/Severity' }
        category: { type: string }
        title: { type: string }
        description: { type: string }
        evidence: { type: string }
        remediation: { type: string }

    FixPrompt:
      type: object
      required: [scan_id, target, scope, prompt, included, truncated, locked_count]
      properties:
        scan_id: { type: string, format: uuid }
        target: { $ref: '#/components/schemas/FixTarget' }
        scope: { type: string, enum: [all, single] }
        prompt: { type: string }
        included: { type: integer, description: Issues the prompt covers. }
        truncated: { type: boolean, description: More than 15 open issues; the rest were cut. }
        locked_count:
          type: integer
          description: Visible issues whose prompt the plan does not open (Free).

    ScanList:
      type: object
      required: [items, next_cursor]
      properties:
        items:
          type: array
          items: { $ref: '#/components/schemas/ScanSummary' }
        next_cursor: { type: [string, 'null'] }

    ScanSummary:
      type: object
      required:
        - id
        - url
        - status
        - risk_score
        - grade
        - critical_count
        - high_count
        - medium_count
        - low_count
        - created_at
        - completed_at
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        status: { type: string }
        risk_score: { type: [number, 'null'], description: null until completed. }
        grade:
          type: [string, 'null']
          enum: ['A+', A, B, C, D, F, null]
        critical_count: { type: integer }
        high_count: { type: integer }
        medium_count: { type: integer }
        low_count: { type: integer }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
