openapi: 3.1.0
info:
  title: Bugnet Public API
  version: "1.0.0"
  summary: SDK ingestion and public-page endpoints.
  description: |
    The endpoints a **game client** or a **public web page** calls. Nothing here
    needs a user session.

    * **SDK endpoints** authenticate with the project's API key (`sk_live_…`),
      found on the project's **Integrate** tab. Most take it in the `X-API-Key`
      header; the two session endpoints take it as `api_key` in the JSON body.
      A key only works while the project's `is_public` flag is on (the default) —
      turning it off makes every SDK call return `401 invalid API key`.
    * **Public page endpoints** (tracker, roadmap, changelog, branding, votes)
      are unauthenticated and keyed by the project slug.

    Every JSON response uses the envelope `{"ok": true, "data": …}` or
    `{"ok": false, "error": "message"}`. The one exception is `GET /health`,
    which returns a bare object.

    **Rate limits** are per client IP. Every request counts against a global
    300/minute budget; these routes also have their own, tighter budget:
    30/minute for most, 120/minute for `/api/perf/snapshot` and
    `/api/session-replays`. Responses carry `X-RateLimit-Limit`,
    `X-RateLimit-Remaining` and `X-RateLimit-Reset` (Unix seconds); a `429`
    also carries `Retry-After` (seconds).

    Every path is also served under `/api/v1/…`.
  contact:
    name: Bugnet
    url: https://bugnet.io/contact
servers:
  - url: https://api.bugnet.io
    description: Production API (the SDKs' default server URL)
  - url: https://bugnet.io
    description: Same API, proxied through the website origin
tags:
  - name: Bug reports
    description: Filing reports and their follow-up uploads from a game.
  - name: SDK configuration
    description: Dashboard-controlled settings the SDKs load on init.
  - name: Sessions
    description: Game sessions that feed crash-free rate and release health.
  - name: Telemetry
    description: Custom events, performance snapshots and session replays.
  - name: Public tracker
    description: The read-only public bug tracker at `/tracker/{slug}`.
  - name: Public roadmap
    description: The public roadmap at `/roadmap/{slug}`.
  - name: Changelog & branding
    description: Data behind `/changelog/{slug}` and public page theming.
  - name: Files
    description: Uploaded screenshots, attachments and replays.
  - name: Status
    description: Service health and maintenance notices.

paths:
  /api/bugs/submit:
    post:
      tags: [Bug reports]
      operationId: submitBug
      summary: Submit a bug report
      description: |
        Files a bug report into the project the API key belongs to. This is
        what every SDK calls.

        **Grouping.** When the project has bug grouping on (the default), a
        report whose title exactly matches an existing report in the project
        is stacked onto it instead of creating a new one: the response is
        `200` with `deduplicated: true`, and the existing report's
        `occurrence_count` goes up. Stacked occurrences still count toward the
        plan's bug report quota.

        **Auto-triage.** When `category` is `other` and `priority` is
        `medium` (the defaults), the server infers both from the report text,
        unless `auto_captured` is true.

        Reports whose title contains `[Bugnet]` or `Bugnet SDK`, or whose
        description contains `failed to report bug`, are rejected to stop
        feedback loops.

        Body limit: 20 MB (base64 screenshot plus up to 10 inline attachments
        of up to 10 MB decoded each).
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BugSubmission'
            example:
              title: Player falls through floor on level 3
              description: Near the bridge the collision mesh has a gap.
              priority: high
              category: gameplay
              platform: Windows
              game_version: 1.2.0
              os_info: Windows 11 23H2
              device_info: RTX 3060, 16 GB RAM
              steps_to_reproduce: "1. Start level 3\n2. Walk to the bridge\n3. Jump near the left railing"
              expected_behavior: Player lands on the bridge
              actual_behavior: Player falls through the floor
              reporter_email: player@example.com
              metadata:
                level: 3
                checkpoint: bridge
      responses:
        '201':
          description: A new report was created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/BugSubmitCreated'
        '200':
          description: The report was stacked onto an existing report with the same title.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/BugSubmitDeduplicated'
        '400':
          description: Missing title/description, invalid priority or category, malformed JSON, or a filtered SDK feedback-loop report.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '403':
          description: The account has reached its plan's bug report limit.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/bugs/{id}/attachments:
    post:
      tags: [Bug reports]
      operationId: uploadBugAttachment
      summary: Attach a file to a submitted report
      description: |
        Uploads one file to a report this API key's project owns — for
        example a full log or save file after an auto-captured crash. Use the
        `id` returned by `POST /api/bugs/submit`.

        The server detects the real type from the file contents; only text,
        image, video and JSON files are accepted, up to 10 MB decoded. Counts
        toward the plan's upload limit.
      security:
        - ApiKeyHeader: []
      parameters:
        - name: id
          in: path
          required: true
          description: The bug report's UUID.
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttachmentInput'
            example:
              filename: player.log
              mime_type: text/plain
              data: data:text/plain;base64,SGVsbG8gZnJvbSB0aGUgZ2FtZQ==
      responses:
        '201':
          description: Attachment stored.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/StoredAttachment'
        '400':
          description: Invalid bug id, empty or non-base64 data, file over 10 MB, or a disallowed file type.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/MissingApiKey' }
        '403':
          description: The account has reached its plan's upload limit.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '404':
          description: No live report with this id in the API key's project.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/bugs/settings:
    get:
      tags: [SDK configuration]
      operationId: getSdkSettings
      summary: Fetch the project's SDK settings
      description: |
        Called by the SDKs on init. Returns the capture toggles set in the
        dashboard and the in-game widget theme. `session_capture` is only ever
        true on the Studio Plus plan; free projects always get the default
        widget theme.
      security:
        - ApiKeyHeader: []
      responses:
        '200':
          description: Settings for the key's project.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        $ref: '#/components/schemas/SdkSettings'
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/sessions/start:
    post:
      tags: [Sessions]
      operationId: startSession
      summary: Start a game session
      description: |
        Records the start of a play session. `session_token` is any unique
        string the client generates (the SDKs use a UUID) and is used again to
        end the session. Passing `steam_id` links the session to a player
        record. Takes the API key in the body, not a header.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionStart'
            example:
              api_key: sk_live_your_project_key
              session_token: 6f1c2a0e-3b7d-4e55-9a41-0c8f2d7e9b13
              platform: Windows
              game_version: 1.2.0
              os_info: Windows 11 23H2
              device_info: RTX 3060, 16 GB RAM
      responses:
        '201':
          description: Session recorded.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          session_id: { type: string, format: uuid }
        '400':
          description: Malformed JSON, or `api_key` / `session_token` missing.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '409':
          description: A session with this token already exists.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/sessions/end:
    post:
      tags: [Sessions]
      operationId: endSession
      summary: End a game session
      description: Marks a session ended. Set `crashed` to true when the session ended in a crash; this drives the crash-free rate.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [api_key, session_token]
              properties:
                api_key: { type: string, description: The project API key. }
                session_token: { type: string, description: The token passed to `/api/sessions/start`. }
                crashed: { type: boolean, default: false }
            example:
              api_key: sk_live_your_project_key
              session_token: 6f1c2a0e-3b7d-4e55-9a41-0c8f2d7e9b13
              crashed: false
      responses:
        '200':
          description: Session end recorded.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          status: { type: string, const: recorded }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: No session with this token in the project.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/events/track:
    post:
      tags: [Telemetry]
      operationId: trackEvent
      summary: Track a custom event
      description: |
        Records one custom analytics event. Requires a plan with event
        tracking (Studio or Studio Plus); the free plan gets `403`. Paid plans
        have a monthly event allowance.
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TrackedEvent'
            example:
              event_name: level_completed
              event_data: { level: 3, time_sec: 184 }
              session_token: 6f1c2a0e-3b7d-4e55-9a41-0c8f2d7e9b13
              platform: Windows
              game_version: 1.2.0
      responses:
        '201':
          description: Event recorded.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
        '400':
          description: Missing `event_name`, or `event_data` over 4 KB once serialized.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '403':
          description: Event tracking is not on this plan, or the monthly event limit is reached.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/events/track/batch:
    post:
      tags: [Telemetry]
      operationId: trackEventBatch
      summary: Track up to 50 events at once
      description: Same rules as `POST /api/events/track`, for 1–50 events in one request. The whole batch is rejected if it would exceed the monthly allowance.
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [events]
              properties:
                events:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    $ref: '#/components/schemas/TrackedEvent'
      responses:
        '201':
          description: Events recorded.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          recorded: { type: integer, description: Number of events stored. }
        '400':
          description: Empty or oversized `events` array, an event missing `event_name`, or `event_data` over 4 KB.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '403':
          description: Event tracking is not on this plan, or the batch would exceed the monthly limit.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/perf/snapshot:
    post:
      tags: [Telemetry]
      operationId: submitPerfSnapshot
      summary: Attach a performance snapshot to a report
      description: Stores performance metrics captured at the moment a bug was reported. Every metric is optional. Rate limit 120/minute.
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PerfSnapshot'
            example:
              bug_report_id: 0b6e3f5c-8a1d-4f2e-9c47-5d3a2b1e0f98
              fps: 42.5
              frame_time_ms: 23.5
              memory_used_mb: 1830
              memory_total_mb: 16384
              draw_calls: 1450
              cpu_usage: 61.2
      responses:
        '201':
          description: Snapshot stored.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
        '400':
          description: Malformed JSON or missing `bug_report_id`.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '404':
          description: No live report with this id in the key's project.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/session-replays:
    post:
      tags: [Telemetry]
      operationId: submitSessionReplay
      summary: Upload a session replay for a report
      description: |
        Uploads a short screen recording (WebM, MP4 or GIF) for a report as
        `multipart/form-data`, up to 50 MB. Requires the Studio Plus plan.
        Rate limit 120/minute.
      security:
        - ApiKeyHeader: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [bug_report_id, file]
              properties:
                bug_report_id: { type: string, format: uuid }
                file:
                  type: string
                  format: binary
                  description: The recording (`video/webm`, `video/mp4` or `image/gif`).
                duration_sec: { type: integer, description: Length of the recording in seconds. }
                metadata: { type: string, description: Free-form string (typically JSON) stored with the replay. }
      responses:
        '201':
          description: Replay stored.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
        '400':
          description: Bad multipart form, missing `bug_report_id` or `file`, or an unsupported file type.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '401': { $ref: '#/components/responses/InvalidApiKey' }
        '403':
          description: The project's account is not on Studio Plus.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '404':
          description: No live report with this id in the key's project.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/tracker/{slug}:
    get:
      tags: [Public tracker]
      operationId: getPublicTracker
      summary: List a project's public bug tracker
      description: Non-private reports, 25 per page, ordered by upvotes then newest. `404` unless the project has its public tracker enabled.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
        - name: status
          in: query
          schema: { $ref: '#/components/schemas/Status' }
        - name: category
          in: query
          schema: { $ref: '#/components/schemas/Category' }
        - name: q
          in: query
          description: Full-text search over title and description. Ignored unless the tracker allows search.
          schema: { type: string }
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: One page of the tracker.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          project: { $ref: '#/components/schemas/PublicProject' }
                          settings: { $ref: '#/components/schemas/TrackerSettings' }
                          bugs:
                            type: array
                            items: { $ref: '#/components/schemas/PublicBug' }
                          counts: { $ref: '#/components/schemas/StatusCounts' }
                          total: { type: integer }
                          page: { type: integer }
                          total_pages: { type: integer }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/tracker/{slug}/bugs/{number}:
    get:
      tags: [Public tracker]
      operationId: getPublicTrackerBug
      summary: Get one public bug with its public comments
      description: Internal comments are never returned.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
        - $ref: '#/components/parameters/Number'
      responses:
        '200':
          description: The bug and its public comments, oldest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          bug:
                            allOf:
                              - $ref: '#/components/schemas/PublicBug'
                              - type: object
                                properties:
                                  description: { type: string }
                          comments:
                            type: array
                            items: { $ref: '#/components/schemas/PublicComment' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/tracker/{slug}/bugs/{number}/vote:
    post:
      tags: [Public tracker]
      operationId: votePublicTrackerBug
      summary: Upvote a bug on the public tracker
      description: |
        Anonymous upvote. One vote per voter: the voter is `player_id` when
        given (e.g. the SDK's player ID), otherwise the client IP. Voting
        twice is not an error — it returns the current count with
        `is_new: false`. The body is optional.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
        - $ref: '#/components/parameters/Number'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VoteRequest' }
      responses:
        '200': { $ref: '#/components/responses/VoteResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403':
          description: Upvoting is turned off for this tracker.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/roadmap/{slug}:
    get:
      tags: [Public roadmap]
      operationId: getPublicRoadmap
      summary: Get a project's public roadmap
      description: Non-private reports in the statuses the roadmap is configured to show (open and in-progress by default), grouped by status and sorted by the roadmap's `sort_order`. `404` unless the roadmap is enabled.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: The roadmap.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          project: { $ref: '#/components/schemas/PublicProject' }
                          settings: { $ref: '#/components/schemas/RoadmapSettings' }
                          bugs:
                            type: array
                            items: { $ref: '#/components/schemas/PublicBug' }
                          counts: { $ref: '#/components/schemas/StatusCounts' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/roadmap/{slug}/bugs/{number}/vote:
    post:
      tags: [Public roadmap]
      operationId: votePublicRoadmapBug
      summary: Upvote a bug on the public roadmap
      description: Same voting rules as the tracker vote. `403` unless the roadmap is enabled and shows upvotes.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
        - $ref: '#/components/parameters/Number'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VoteRequest' }
      responses:
        '200': { $ref: '#/components/responses/VoteResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '403':
          description: Upvoting is not enabled on this roadmap.
          content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/projects/{slug}/changelog:
    get:
      tags: [Changelog & branding]
      operationId: listPublishedChangelog
      summary: List published changelog entries
      description: Up to 50 published entries, newest first. Drafts are visible only through the authenticated `GET /api/projects/{slug}/changelog/all`.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Published entries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ChangelogEntry' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/branding/{slug}:
    get:
      tags: [Changelog & branding]
      operationId: getPublicBranding
      summary: Get a project's public branding
      description: Colors, logo and company details used to theme the project's public pages.
      security: []
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Branding settings (defaults when none are saved).
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Branding' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/files/{key}:
    get:
      tags: [Files]
      operationId: getFile
      summary: Download an uploaded file
      description: |
        Serves screenshots, attachments and replays by the storage key found
        in their `file_url` / `screenshot_url` (which already include the
        `/api/files/` prefix). Keys may contain slashes. Responses are
        immutable and cached for a year. Anything other than a non-SVG image
        or a video is sent with `Content-Disposition: attachment`.
      security: []
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The raw file, with its stored content type.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/maintenance/active:
    get:
      tags: [Status]
      operationId: getActiveMaintenance
      summary: Get the active maintenance banner
      description: The maintenance window currently in effect, or `data` omitted when there is none.
      security: []
      responses:
        '200':
          description: The active banner, if any.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/OkEnvelope'
                  - type: object
                    properties:
                      data:
                        type: [object, 'null']
                        properties:
                          id: { type: string }
                          title: { type: string }
                          message: { type: string }
                          banner_color: { type: string }
                          text_color: { type: string }
                          scheduled_start: { type: [string, 'null'] }
                          scheduled_end: { type: [string, 'null'] }
        '429': { $ref: '#/components/responses/RateLimited' }

  /health:
    get:
      tags: [Status]
      operationId: healthCheck
      summary: Health check
      description: Checks that the API can reach its database. Not wrapped in the response envelope and not rate limited beyond the global budget. Not available under `/api/v1`.
      security: []
      responses:
        '200':
          description: Healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: healthy }
        '503':
          description: The database is unreachable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, const: unhealthy }
                  error: { type: string }

components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: The project API key (`sk_live_…`) from the project's Integrate tab.

  parameters:
    Slug:
      name: slug
      in: path
      required: true
      description: The project slug.
      schema: { type: string }
    Number:
      name: number
      in: path
      required: true
      description: The report number within the project (the `#12` shown in the dashboard), not the UUID.
      schema: { type: integer, minimum: 1 }
    Page:
      name: page
      in: query
      description: 1-based page number.
      schema: { type: integer, minimum: 1, default: 1 }

  responses:
    BadRequest:
      description: The request was malformed.
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    NotFound:
      description: The project, page or record does not exist or is not public.
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    MissingApiKey:
      description: The `X-API-Key` header is missing.
      content: { application/json: { schema: { $ref: '#/components/schemas/ErrorEnvelope' } } }
    InvalidApiKey:
      description: The API key is missing, unknown, or belongs to a project with `is_public` off.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example: { ok: false, error: invalid API key }
    RateLimited:
      description: Too many requests from this IP. Retry after `Retry-After` seconds.
      headers:
        Retry-After:
          schema: { type: integer }
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer }
          description: Unix time the window resets.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example: { ok: false, error: 'rate limit exceeded, try again later' }
    VoteResult:
      description: The bug's upvote total after this request.
      content:
        application/json:
          schema:
            allOf:
              - $ref: '#/components/schemas/OkEnvelope'
              - type: object
                properties:
                  data:
                    type: object
                    properties:
                      upvotes: { type: integer }
                      is_new: { type: boolean, description: False when this voter had already voted. }

  schemas:
    OkEnvelope:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean, const: true }
        data: {}
    ErrorEnvelope:
      type: object
      required: [ok, error]
      properties:
        ok: { type: boolean, const: false }
        error: { type: string }

    Priority:
      type: string
      enum: [critical, high, medium, low]
    Category:
      type: string
      enum: [crash, visual, gameplay, performance, audio, ui, network, other]
    Status:
      type: string
      enum: [open, in_progress, resolved, closed, wont_fix]

    AttachmentInput:
      type: object
      required: [data]
      properties:
        filename: { type: string, description: Display name; path components are stripped. }
        mime_type: { type: string, description: Hint only — the server detects the real type. }
        data: { type: string, description: 'Base64 file contents, optionally with a data-URI prefix (`data:text/plain;base64,…`).' }

    BugSubmission:
      type: object
      required: [title, description]
      properties:
        title: { type: string, maxLength: 300 }
        description: { type: string, description: 'Truncated to 60,000 bytes.' }
        priority: { allOf: [{ $ref: '#/components/schemas/Priority' }], default: medium }
        category: { allOf: [{ $ref: '#/components/schemas/Category' }], default: other }
        platform: { type: string, maxLength: 50 }
        game_version: { type: string, maxLength: 50 }
        os_info: { type: string, maxLength: 100 }
        device_info: { type: string, maxLength: 200 }
        steps_to_reproduce: { type: string }
        expected_behavior: { type: string }
        actual_behavior: { type: string }
        screenshot: { type: string, description: Base64-encoded image. }
        steam_id: { type: string, maxLength: 30, description: Links the report to a player record. }
        reporter_name: { type: string, maxLength: 100 }
        reporter_email: { type: string, format: email, description: Dropped if not a valid address. }
        metadata:
          description: Any JSON value, up to 16 KB, stored verbatim with the report. Ignored if invalid or oversized.
        attachments:
          type: array
          maxItems: 10
          description: Extra files stored with the report. Extras beyond 10, and files that fail validation, are skipped without failing the request.
          items: { $ref: '#/components/schemas/AttachmentInput' }
        auto_captured:
          type: boolean
          default: false
          description: Set when the SDK filed the report itself from an engine error. Skips keyword auto-triage so the SDK's own priority/category stand.

    BugSubmitCreated:
      type: object
      properties:
        id: { type: string, format: uuid }
        report_number: { type: integer }
        message: { type: string }
        category: { $ref: '#/components/schemas/Category' }
        priority: { $ref: '#/components/schemas/Priority' }
        sentiment_score: { type: number }
        sentiment_level: { type: string }
        possible_duplicates:
          type: [array, 'null']
          description: Report numbers of up to 3 open reports with similar text.
          items: { type: integer }

    BugSubmitDeduplicated:
      type: object
      properties:
        id: { type: string, format: uuid, description: The existing report the occurrence was stacked onto. }
        report_number: { type: integer }
        message: { type: string }
        occurrence_count: { type: integer }
        deduplicated: { type: boolean, const: true }

    StoredAttachment:
      type: object
      properties:
        id: { type: string, format: uuid }
        file_url: { type: string, description: 'Path under `/api/files/`.' }
        file_name: { type: string }
        file_size: { type: integer }
        mime_type: { type: string }

    SdkSettings:
      type: object
      properties:
        screenshot_capture: { type: boolean }
        session_capture: { type: boolean, description: Only true on Studio Plus. }
        auto_file_errors: { type: boolean }
        capture_editor_errors: { type: boolean }
        capture_warnings: { type: boolean }
        widget: { $ref: '#/components/schemas/WidgetSettings' }

    WidgetSettings:
      type: object
      description: In-game report widget theme. Hover colors may be empty, meaning "derive from the base color".
      properties:
        project_id: { type: string }
        panel_color: { type: string }
        accent_color: { type: string }
        text_color: { type: string }
        header_color: { type: string }
        input_bg_color: { type: string }
        input_border_color: { type: string }
        overlay_color: { type: string }
        overlay_opacity: { type: number }
        submit_color: { type: string }
        submit_hover_color: { type: string }
        submit_text_color: { type: string }
        cancel_color: { type: string }
        cancel_hover_color: { type: string }
        cancel_text_color: { type: string }
        header_text: { type: string }
        title_placeholder: { type: string }
        desc_placeholder: { type: string }
        steps_placeholder: { type: string }
        submit_label: { type: string }
        cancel_label: { type: string }
        header_font_size: { type: integer }
        body_font_size: { type: integer }
        corner_radius: { type: integer }
        max_panel_width: { type: integer }
        panel_width_pct: { type: number }
        max_panel_height: { type: integer }
        separation: { type: integer }
        panel_margin: { type: integer }
        position: { type: string }
        fade_in_ms: { type: integer }
        fade_out_ms: { type: integer }
        categories: { type: array, items: { type: string } }
        priorities: { type: array, items: { type: string } }
        show_steps: { type: boolean }
        show_category: { type: boolean }
        show_priority: { type: boolean }
        show_email: { type: boolean }
        background_image_url: { type: string }
        background_mode: { type: string }
        hide_branding: { type: boolean }

    SessionStart:
      type: object
      required: [api_key, session_token]
      properties:
        api_key: { type: string, description: The project API key. }
        session_token: { type: string, description: A unique client-generated ID for this session. }
        platform: { type: string }
        game_version: { type: string }
        device_info: { type: string, maxLength: 200 }
        os_info: { type: string, maxLength: 255 }
        steam_id: { type: string, maxLength: 30 }
        player_name: { type: string, maxLength: 100 }

    TrackedEvent:
      type: object
      required: [event_name]
      properties:
        event_name: { type: string, maxLength: 100 }
        event_data:
          type: object
          additionalProperties: true
          description: Any JSON object, up to 4 KB serialized.
        session_token: { type: string, maxLength: 128 }
        platform: { type: string, maxLength: 50 }
        game_version: { type: string, maxLength: 50 }

    PerfSnapshot:
      type: object
      required: [bug_report_id]
      properties:
        bug_report_id: { type: string, format: uuid }
        fps: { type: number }
        frame_time_ms: { type: number }
        memory_used_mb: { type: number }
        memory_total_mb: { type: number }
        draw_calls: { type: integer }
        triangles: { type: integer }
        cpu_usage: { type: number }
        gpu_usage: { type: number }
        load_time_sec: { type: number }
        network_latency_ms: { type: integer }
        custom_metrics: { type: string, description: 'Free-form string, typically a JSON object encoded as a string.' }

    VoteRequest:
      type: object
      properties:
        player_id:
          type: string
          description: Stable player identifier. When omitted, the client IP identifies the voter.

    PublicProject:
      type: object
      properties:
        name: { type: string }
        description: { type: [string, 'null'] }
        logo_url: { type: [string, 'null'] }
        slug: { type: string }

    PublicBug:
      type: object
      properties:
        number: { type: integer }
        title: { type: string }
        status: { $ref: '#/components/schemas/Status' }
        priority: { $ref: '#/components/schemas/Priority' }
        category: { $ref: '#/components/schemas/Category' }
        upvotes: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PublicComment:
      type: object
      properties:
        id: { type: string }
        body: { type: string }
        created_at: { type: string, format: date-time }
        author: { type: [string, 'null'], description: Display name of the team member. }

    StatusCounts:
      type: object
      properties:
        open: { type: integer }
        in_progress: { type: integer }
        resolved: { type: integer }
        closed: { type: integer }

    TrackerSettings:
      type: object
      properties:
        enabled: { type: boolean }
        page_title: { type: [string, 'null'] }
        page_description: { type: [string, 'null'] }
        accent_color: { type: string }
        logo_url: { type: [string, 'null'] }
        allow_search: { type: boolean }
        allow_upvotes: { type: boolean }
        show_categories: { type: boolean }
        show_priority: { type: boolean }
        show_status_filter: { type: boolean }
        custom_css: { type: [string, 'null'] }

    RoadmapSettings:
      type: object
      properties:
        enabled: { type: boolean }
        page_title: { type: [string, 'null'] }
        page_description: { type: [string, 'null'] }
        accent_color: { type: string }
        logo_url: { type: [string, 'null'] }
        show_open: { type: boolean }
        show_in_progress: { type: boolean }
        show_resolved: { type: boolean }
        show_closed: { type: boolean }
        show_bug_count: { type: boolean }
        show_upvotes: { type: boolean }
        show_categories: { type: boolean }
        show_priority: { type: boolean }
        show_footer: { type: boolean, description: Always true on the free plan. }
        layout_mode: { type: string }
        header_align: { type: string }
        sort_order: { type: string, enum: [priority, newest, upvotes] }
        custom_css: { type: [string, 'null'] }

    ChangelogEntry:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        body: { type: string }
        version: { type: [string, 'null'] }
        published_at: { type: [string, 'null'], format: date-time }
        author_name: { type: string }

    Branding:
      type: object
      properties:
        project_id: { type: string }
        logo_url: { type: [string, 'null'] }
        accent_color: { type: string }
        background_color: { type: string }
        text_color: { type: string }
        company_name: { type: [string, 'null'] }
        support_email: { type: [string, 'null'] }
        custom_css: { type: [string, 'null'] }
        hide_bugnet_badge: { type: boolean, description: Always false for projects on the free plan. }
