openapi: 3.0.3
info:
  title: Skintick API
  version: "0.1.0"
  description: |
    Pricing API for Counter-Strike 2 skins across the major marketplaces.

    Skintick normalizes per-marketplace prices onto a single response shape
    keyed by Steam's `market_hash_name`, layers freshness metadata on every row,
    and adds cross-market outlier + confidence flags so downstream code can
    filter out noise before doing spread math. Live reads come from a
    replace-on-write `current_prices` view; historical reads come from compact
    daily close rows instead of raw ingestion snapshots.

    Every `/v1` endpoint answers CORS preflight and allows any origin, so
    browser code can call it directly. A key placed in client-side code is
    visible to whoever loads the page; keep keys server-side for anything
    public, and regenerate a key from the dashboard if it leaks.

    ## Authentication

    All `/v1/*` endpoints require an API key in the `Authorization` header:

        Authorization: Bearer skt_<43 chars base64url>

    Keys are minted the moment you sign up (free tier, no card) or through
    paid Checkout. Each key has its own per-minute, per-hour, and per-day rate
    limits; the response includes `X-RateLimit-*` headers on every call.

    Free keys are for evaluation: last 7 days of price history, no
    `bid_price` values, 25-item batches, no `/v1/spreads`. Paid keys get full
    history depth, the bid side, 100-item batches, and spread routes.
    Upgrading flips your existing keys to paid in place — integrations built
    on a free key start receiving paid data without a key swap.

    `/healthz` and `/v1/status` are public for uptime monitors and status
    pages.

    ## Conventions

    - **Prices** are returned as decimal strings (`"22.40"`) to avoid float
      drift on the consumer side. They are stored as integer cents internally.
    - **Timestamps** are RFC 3339 in UTC.
    - **`market_hash_name`** is the Steam canonical name including wear
      (`"AK-47 | Redline (Field-Tested)"`). Path parameters must be URL-encoded.
    - **Currency** is USD across every marketplace (we convert at the boundary).
    - Errors use a consistent envelope: `{ "error": { "code", "message" } }`.

  contact:
    name: Skintick
    url: https://skintick.io
  license:
    name: Proprietary

servers:
  - url: https://api.skintick.io
    description: Production

tags:
  - name: Health
    description: Service health and per-marketplace data freshness. No auth.
  - name: Markets
    description: Live marketplaces exposed through price endpoints.
  - name: Items
    description: Item discovery, latest cross-market prices, and per-market history.
  - name: Prices
    description: Batch latest-price lookups optimized for bots/tools.
  - name: Spreads
    description: Cross-marketplace arbitrage routes with fees + trade-lock risk.

security:
  - bearerAuth: []

paths:
  /healthz:
    get:
      summary: Liveness check
      description: Pings the database. Returns `200 {"status":"ok"}` when both API and Postgres are reachable.
      tags: [Health]
      security: []
      responses:
        "200":
          description: API is alive.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    example: ok
        "503":
          description: API is up but the database is unreachable.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/status:
    get:
      summary: Per-marketplace data freshness
      description: |
        Durable freshness signal for each marketplace Skintick tracks: whether
        its data is current, when it was last updated, and whether the most
        recent update succeeded. No auth.

        Status values:
        - `healthy`: last successful run is newer than 2× that market's configured interval.
        - `stale`: last successful run is newer than 4× the interval, but older than the healthy window.
        - `degraded`: the market succeeded before, but the last success is older than 4× the interval.
        - `unavailable`: runs exist, but none have ever succeeded.
        - `unknown`: no run on record yet.
        - `disabled`: the source is intentionally paused; freshness is not evaluated.
      tags: [Health]
      security: []
      responses:
        "200":
          description: Status for every configured marketplace, including parked ones.
          content:
            application/json:
              schema:
                type: object
                required: [markets]
                properties:
                  markets:
                    type: array
                    items:
                      $ref: "#/components/schemas/MarketStatus"

  /v1/markets:
    get:
      summary: List supported marketplaces
      description: |
        Slug, display name, type, fees, and trade-lock days for live enabled
        marketplaces. Disabled or parked sources are hidden by default so
        customer-facing integrations don't treat stale sources as available.
      tags: [Markets]
      parameters:
        - name: include_disabled
          in: query
          required: false
          description: Set to `true` to include parked/disabled marketplaces for operator or debugging views.
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Enabled marketplaces by default; all configured marketplaces when `include_disabled=true`.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Marketplace"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/items:
    get:
      summary: Search / list items
      description: |
        Paginated catalog of items Skintick has ever seen. Cursor-based,
        ordered by liquidity — listings × √(lowest ask), refreshed every few
        minutes — so the first page is the most-traded items, then by id for
        a stable walk. The cursor is opaque; pass it back verbatim.

        Filters compose with AND. `q` is the only substring filter; the rest
        match parsed attributes exactly. Combine them to scope to e.g. "all
        StatTrak AK-47 Field-Tested skins" without a substring scan.
      tags: [Items]
      parameters:
        - name: q
          in: query
          description: Case-insensitive substring filter on `market_hash_name`.
          required: false
          schema:
            type: string
            example: AK-47
        - name: category
          in: query
          description: Exact match on parsed item category.
          required: false
          schema:
            $ref: "#/components/schemas/ItemCategory"
        - name: weapon
          in: query
          description: |
            Exact weapon name as it appears on Steam, including the `★ `
            prefix for knives and gloves (`AK-47`, `★ Karambit`,
            `★ Sport Gloves`).
          required: false
          schema:
            type: string
            example: AK-47
        - name: skin
          in: query
          description: Exact skin/finish name (`Redline`, `Doppler`, `Asiimov`).
          required: false
          schema:
            type: string
            example: Redline
        - name: wear
          in: query
          description: Exact wear tier as Steam writes it.
          required: false
          schema:
            type: string
            enum: [Factory New, Minimal Wear, Field-Tested, Well-Worn, Battle-Scarred]
        - name: variant
          in: query
          required: false
          description: Keep only items with this finish (Doppler phases and gem finishes).
          schema:
            $ref: "#/components/schemas/ItemVariant"
        - name: stattrak
          in: query
          description: Set to `true` to keep only StatTrak™ items, `false` to exclude them.
          required: false
          schema:
            type: boolean
        - name: souvenir
          in: query
          description: Set to `true` to keep only Souvenir items, `false` to exclude them.
          required: false
          schema:
            type: boolean
        - name: limit
          in: query
          description: Max rows per page.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          description: Opaque cursor from a previous response's `next_cursor`.
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Item page with optional `next_cursor`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ItemList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/items/slugs:
    get:
      summary: List item page slugs
      description: |
        Pages every base item's URL slug, cursor-paginated. Primarily backs
        the sitemap generation for skintick.io's per-item pages.
      tags: [Items]
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 10000, default: 10000 }
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from the previous page's `next_cursor`.
          schema: { type: string }
      responses:
        "200":
          description: One page of slugs.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { type: string }
                  next_cursor:
                    type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/items/toplist:
    get:
      summary: Ranked item toplist
      description: |
        Ranked card grids backing skintick.io's browse pages. `popular` is
        liquidity-weighted (sqrt(price) x listings) so it surfaces widely
        traded items; `price` is the highest cross-market lowest ask,
        restricted to items live on at least two marketplaces. Rows always
        carry a slug and an image and exclude phase/gem variants.
      tags: [Items]
      parameters:
        - name: class
          in: query
          required: true
          schema:
            type: string
            enum: [rifles, snipers, pistols, smgs, heavy, knives, gloves, cases, stickers, agents, all]
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [popular, price], default: popular }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 24 }
      responses:
        "200":
          description: Ranked items, best first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [market_hash_name, slug, image_url, price, markets, listings]
                      properties:
                        market_hash_name: { type: string }
                        slug: { type: string }
                        wear: { type: string }
                        image_url: { type: string }
                        price:
                          type: string
                          description: Lowest current ask across live marketplaces, USD decimal string.
                        markets:
                          type: integer
                          description: Number of marketplaces with live stock.
                        listings:
                          type: integer
                          description: Total live listings across marketplaces.
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/items/by-slug/{slug}:
    get:
      summary: Item detail by page slug
      description: |
        Resolves a public page slug to full item detail plus the extras the
        per-item pages render: recent cross-market lowest daily closes
        (`history`), the wear ladder for the same skin (`wears`), and
        same-weapon internal links (`related`).
      tags: [Items]
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
          example: ak-47-redline-field-tested
      responses:
        "200":
          description: Item detail with page extras.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/ItemDetail"
                  - type: object
                    required: [slug, history, wears, related]
                    properties:
                      slug: { type: string }
                      class:
                        type: string
                        description: Browse-page class slug (rifles, knives, ...); absent when the item maps to none.
                        enum: [rifles, snipers, pistols, smgs, heavy, knives, gloves, cases, stickers, agents]
                      history:
                        type: array
                        items:
                          type: object
                          required: [date, price]
                          properties:
                            date: { type: string, format: date }
                            price: { type: string, example: "29.03" }
                      wears:
                        type: array
                        items:
                          type: object
                          required: [wear, slug]
                          properties:
                            wear: { type: string }
                            slug: { type: string }
                            price: { type: string, nullable: true }
                      related:
                        type: array
                        items:
                          type: object
                          required: [market_hash_name, slug]
                          properties:
                            market_hash_name: { type: string }
                            slug: { type: string }
                            wear: { type: string, nullable: true }
                            price: { type: string, nullable: true }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/items/{market_hash_name}:
    get:
      summary: Latest cross-market prices for one item
      description: |
        One available current row per marketplace for the requested item.
        Rows include freshness fields (`collected_at`, `is_stale`), provenance
        (`price_basis`), and cross-market quality flags (`is_outlier`,
        `confidence`).
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/MarketHashName"
      responses:
        "200":
          description: Latest prices across every marketplace where we have data.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ItemDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/items/{market_hash_name}/history:
    get:
      summary: Daily price history time series
      description: |
        Daily close prices for one (item, marketplace), ascending. Capped at
        `limit` rows.

        Non-paid keys can query the last 7 days (with a one-day grace margin
        below the boundary, so `from = now-7d` computed on your own clock
        always works); requesting clearly older data returns
        `403 upgrade_required`. Paid keys get full depth.
      tags: [Items]
      parameters:
        - $ref: "#/components/parameters/MarketHashName"
        - name: market
          in: query
          required: true
          description: Marketplace slug from `/v1/markets`.
          schema:
            type: string
            example: skinport
        - name: from
          in: query
          required: false
          description: Inclusive lower bound, RFC 3339.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          description: Inclusive upper bound, RFC 3339.
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 500
      responses:
        "200":
          description: Time series of daily close prices.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HistoryResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/prices/latest:
    post:
      summary: Batch latest prices
      description: |
        Look up up to 100 unique items at once for paid keys, or 25 for
        non-paid keys — the cap counts names after duplicates and empty
        strings are dropped. This is the recommended shape for bots/dashboards
        that need many quotes per cycle — one round-trip instead of one per
        item.

        Items the API doesn't recognize are returned under `missing`. Warnings
        for unknown or disabled market slugs — and `bid_paid_only` on non-paid
        keys — come back under `warnings` without failing the request.
      tags: [Prices]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LatestPricesRequest"
            examples:
              two_items_filtered:
                summary: Two items, scoped to Skinport
                value:
                  items:
                    - "AK-47 | Redline (Field-Tested)"
                    - "Glove Case"
                  markets: ["skinport"]
      responses:
        "200":
          description: Batch response with `data`, `missing`, and `warnings`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LatestPricesResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/spreads:
    get:
      summary: Best cross-market arbitrage routes
      description: |
        For every item with at least 3 priced p2p marketplaces, returns the
        best buy→sell route after fees and a coarse trade-lock risk
        adjustment. Outliers are excluded by default; runaway pairs (over
        1000% risk-adjusted) are dropped as obvious data artifacts. Sorted
        by `risk_adjusted_net_spread_pct` DESC.

        The `risk_model` envelope describes how trade-lock risk was computed
        — currently a coarse `days × 0.5%` approximation. A volatility-based
        model is coming once we have multi-week history.
      tags: [Spreads]
      parameters:
        - name: min_risk_adjusted_spread
          in: query
          description: Minimum risk-adjusted net spread in percent.
          required: false
          schema:
            type: number
            format: double
            default: 0
        - name: min_price
          in: query
          required: false
          description: |
            Minimum buy-leg price in dollars. Percentage spreads on sub-dollar
            items are dominated by rounding (a $0.01 ask against a $0.11 ask
            reads as 1000% but is one cent of noise), so the default keeps them
            out of the ranking. Pass `min_price=0` to include them.
          schema:
            type: number
            format: double
            default: 1
            example: 5
        - name: min_listings
          in: query
          description: Minimum listings_count on both legs.
          required: false
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: buy_market
          in: query
          description: CSV of allowed buy-side marketplace slugs.
          required: false
          schema:
            type: string
            example: skinport,whitemarket
        - name: sell_market
          in: query
          description: CSV of allowed sell-side marketplace slugs.
          required: false
          schema:
            type: string
            example: csfloat,dmarket
        - name: include_outliers
          in: query
          description: Set to `true` to keep prices flagged as cross-market outliers in the route search.
          required: false
          schema:
            type: boolean
            default: false
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: cursor
          in: query
          description: Opaque offset cursor from a previous response.
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Best routes per item, sorted by risk-adjusted net spread.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SpreadsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "skt_<43-char base64url>"
      description: |
        API key minted via Skintick. Pass it on every `/v1/*` request:
        `Authorization: Bearer skt_<43 chars base64url>`.

  parameters:
    MarketHashName:
      name: market_hash_name
      in: path
      required: true
      description: |
        Steam canonical name (including wear). Must be URL-encoded —
        spaces become `%20`, the `|` separator becomes `%7C`, parentheses
        encode to `%28`/`%29`.
      schema:
        type: string
      example: "AK-47%20%7C%20Redline%20%28Field-Tested%29"

  responses:
    BadRequest:
      description: The request was malformed. See the `error.code` for which field failed validation.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing, invalid, or revoked API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: The key is valid, but the plan does not include this endpoint.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: The item or resource isn't in our index.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Per-key rate limit exceeded.
      headers:
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer, description: Unix timestamp when the bucket refills. }
        X-RateLimit-Limit-Hour:
          schema: { type: integer }
        X-RateLimit-Remaining-Hour:
          schema: { type: integer }
        X-RateLimit-Limit-Day:
          schema: { type: integer }
        X-RateLimit-Remaining-Day:
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: invalid_cursor
            message:
              type: string
              example: cursor is malformed

    MarketType:
      type: string
      enum: [p2p, instant_bot, official]
      description: |
        - `p2p`: traditional listing-driven marketplace (Skinport, CSFloat, DMarket, …).
        - `instant_bot`: bot-inventory marketplace where prices carry an instant-trade premium (Tradeit, Loot.farm).
        - `official`: the Steam Community Market. Priced in Steam wallet funds, which are not
          withdrawable cash, so it is never used as the sell leg of a `/v1/spreads` route.

    Marketplace:
      type: object
      required: [slug, display_name, type, buyer_fee_pct, seller_fee_pct, trade_lock_days, enabled]
      properties:
        slug:
          type: string
          example: skinport
        display_name:
          type: string
          example: Skinport
        type:
          $ref: "#/components/schemas/MarketType"
        buyer_fee_pct:
          type: number
          format: double
          example: 0
        seller_fee_pct:
          type: number
          format: double
          example: 12
        trade_lock_days:
          type: integer
          example: 0
        enabled:
          type: boolean
          description: Whether this marketplace is currently exposed through customer-facing read endpoints.
          example: true

    MarketStatusValue:
      type: string
      enum: [healthy, stale, degraded, unavailable, unknown, disabled]

    MarketStatus:
      type: object
      required: [market, market_type, status]
      properties:
        market:
          type: string
          example: skinport
        market_type:
          $ref: "#/components/schemas/MarketType"
        status:
          $ref: "#/components/schemas/MarketStatusValue"
        last_success_at:
          type: string
          format: date-time
          nullable: true
        last_error_at:
          type: string
          format: date-time
          nullable: true
        last_error:
          type: string
          nullable: true
        last_run_duration_ms:
          type: integer
          format: int64
          nullable: true

    ItemVariant:
      type: string
      description: |
        A finish Steam does not distinguish in `market_hash_name` but that
        trades at very different prices — Doppler and Gamma Doppler phases and
        the rare gem finishes. Absent on ordinary items.

        Every phase of "★ Bayonet | Doppler (Factory New)" shares one Steam
        name, so Skintick keeps the canonical name and carries the finish here.
        Sources spell these differently ("Doppler Phase 4 (Minimal Wear)",
        "Doppler (Factory New) - Phase 3"); they are normalized to these values
        so prices for the same physical item line up across marketplaces.
      enum: [phase_1, phase_2, phase_3, phase_4, ruby, sapphire, black_pearl, emerald]
      example: phase_2

    ItemCategory:
      type: string
      enum:
        - skin
        - knife
        - glove
        - sticker
        - charm
        - music_kit
        - graffiti
        - patch
        - case
        - capsule
        - collectible
        - agent
        - unknown
      description: |
        Parsed item family. Skins/knives/gloves carry weapon + skin + wear.
        Stickers/charms/music_kits/graffiti/patches use the freeform name
        in `skin`. Cases/capsules/collectibles/agents have no wear and
        usually no parsed weapon. `unknown` is the fallback for names
        whose shape doesn't match any known category.

    ItemListEntry:
      type: object
      required: [market_hash_name, is_stattrak, is_souvenir, first_seen_at, last_seen_at]
      properties:
        market_hash_name:
          type: string
          example: "AK-47 | Redline (Field-Tested)"
        slug:
          type: string
          description: URL identifier for this item's public page (skintick.io/items/{slug}/).
          example: "ak-47-redline-field-tested"
        category:
          allOf:
            - $ref: "#/components/schemas/ItemCategory"
          nullable: true
        weapon:
          type: string
          nullable: true
          example: "AK-47"
        skin:
          type: string
          nullable: true
          example: "Redline"
        wear:
          type: string
          nullable: true
          enum: [Factory New, Minimal Wear, Field-Tested, Well-Worn, Battle-Scarred]
          example: Field-Tested
        is_stattrak:
          type: boolean
        is_souvenir:
          type: boolean
        first_seen_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time

    ItemList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ItemListEntry"
        next_cursor:
          type: string
          description: Pass back as `cursor` to fetch the next page. Omitted on the last page.

    PriceBasis:
      type: string
      enum: [lowest_ask, aggregated_min, suggested]
      description: |
        How to interpret the row's primary price:
        - `lowest_ask`: direct current ask observed from listings/inventory.
        - `aggregated_min`: source or Skintick aggregate over multiple offers.
        - `suggested`: marketplace reference price rather than an executable ask.

    ConfidenceTier:
      type: string
      enum: [high, medium, low, unknown]
      description: |
        Heuristic combining row age and `listings_count`:
        - `high`: fresh (≤10 min) and deep (≥10 listings).
        - `medium`: one dimension is weaker but still usable (e.g. 3–9 listings or 10–30 min old).
        - `low`: thin books (1–2 listings) or rows older than 30 min.
        - `unknown`: the marketplace didn't report a count.

    MarketPriceRow:
      type: object
      description: One marketplace's current available price row for an item.
      required: [market, market_type, price_basis, currency, collected_at, staleness_seconds, is_stale, is_outlier, confidence]
      properties:
        market:
          type: string
          example: skinport
        market_type:
          $ref: "#/components/schemas/MarketType"
        price_basis:
          $ref: "#/components/schemas/PriceBasis"
        currency:
          type: string
          example: USD
        min_price:
          type: string
          nullable: true
          description: Lowest current ask, as a decimal string.
          example: "32.79"
        max_price:
          type: string
          nullable: true
          description: Source-reported maximum. May reflect historical extremes on some marketplaces.
          example: "20532.60"
        median_price:
          type: string
          nullable: true
        mean_price:
          type: string
          nullable: true
        suggested_price:
          type: string
          nullable: true
          description: Marketplace's own reference price (e.g. predicted_price on CSFloat).
        bid_price:
          type: string
          nullable: true
          description: |
            Highest standing buy order, as a decimal string — what the market's
            best bidder is currently willing to pay. Paid keys only: non-paid
            keys always receive `null` here (the batch endpoint flags this
            with a `bid_paid_only` warning). `null` also on marketplaces that
            publish no bid side (most of them today); a null bid is not the same
            as "no open bids". Together with `min_price` this gives the true
            bid/ask spread on sources that support it.
          example: "31.50"
        listings_count:
          type: integer
          nullable: true
        collected_at:
          type: string
          format: date-time
          description: When Skintick collected this snapshot.
        source_updated_at:
          type: string
          format: date-time
          nullable: true
          description: When the source marketplace last updated the row (when available).
        staleness_seconds:
          type: integer
          format: int64
          description: Seconds since `collected_at`. Local to the request time.
        is_stale:
          type: boolean
          description: True when `collected_at` is older than 30 minutes.
        is_outlier:
          type: boolean
          description: |
            True when this marketplace's `min_price` is outside 3× the
            cross-marketplace median. Requires ≥3 priced markets to fire;
            below that floor we leave it false.
        confidence:
          $ref: "#/components/schemas/ConfidenceTier"

    ItemDetail:
      type: object
      required: [market_hash_name, is_stattrak, is_souvenir, prices]
      properties:
        market_hash_name:
          type: string
        image_url:
          type: string
          description: Steam CDN render for this item; absent when no image is known.
        category:
          allOf:
            - $ref: "#/components/schemas/ItemCategory"
          nullable: true
        weapon:
          type: string
          nullable: true
        skin:
          type: string
          nullable: true
        wear:
          type: string
          nullable: true
          enum: [Factory New, Minimal Wear, Field-Tested, Well-Worn, Battle-Scarred]
        is_stattrak:
          type: boolean
        is_souvenir:
          type: boolean
        prices:
          type: array
          items:
            $ref: "#/components/schemas/MarketPriceRow"

    HistoryPoint:
      type: object
      required: [collected_at, price_basis]
      properties:
        collected_at:
          type: string
          format: date-time
          description: Time the daily close price was last observed.
        price_basis:
          $ref: "#/components/schemas/PriceBasis"
        min_price:
          type: string
          nullable: true
          description: Daily close price, retained as `min_price` for response compatibility.
        listings_count:
          type: integer
          nullable: true
        source_updated_at:
          type: string
          format: date-time
          nullable: true

    HistoryResponse:
      type: object
      required: [market_hash_name, market, data]
      properties:
        market_hash_name:
          type: string
        market:
          type: string
        data:
          type: array
          items:
            $ref: "#/components/schemas/HistoryPoint"

    LatestPricesRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            type: string
          description: Up to 100 unique market_hash_names for paid keys; non-paid keys are capped at 25. Duplicates and empty strings are dropped before the cap is applied.
        markets:
          type: array
          items:
            type: string
          description: Optional marketplace-slug filter. Unknown or disabled slugs are reported under `warnings` instead of failing.

    LatestPriceRow:
      type: object
      required: [market, market_type, price_basis, currency, collected_at, staleness_seconds, is_stale, is_outlier, confidence]
      description: |
        Like `MarketPriceRow` but trimmed for batch lookups, and with explicit
        staleness fields so consumers can spot dead data without computing it.
      properties:
        market:
          type: string
        market_type:
          $ref: "#/components/schemas/MarketType"
        price_basis:
          $ref: "#/components/schemas/PriceBasis"
        price:
          type: string
          nullable: true
          example: "32.79"
        bid_price:
          type: string
          nullable: true
          description: |
            Highest standing buy order, as a decimal string — what the market's
            best bidder is currently willing to pay. Paid keys only: non-paid
            keys always receive `null` here (the batch endpoint flags this
            with a `bid_paid_only` warning). `null` also on marketplaces that
            publish no bid side (most of them today); a null bid is not the same
            as "no open bids". Together with `min_price` this gives the true
            bid/ask spread on sources that support it.
          example: "31.50"
        currency:
          type: string
          example: USD
        listings_count:
          type: integer
          nullable: true
        collected_at:
          type: string
          format: date-time
        source_updated_at:
          type: string
          format: date-time
          nullable: true
        staleness_seconds:
          type: integer
          format: int64
          description: Seconds since `collected_at`. Local to the request time.
        is_stale:
          type: boolean
          description: True when `collected_at` is older than 30 minutes.
        is_outlier:
          type: boolean
        confidence:
          $ref: "#/components/schemas/ConfidenceTier"

    LatestPricesItem:
      type: object
      required: [market_hash_name, prices]
      properties:
        market_hash_name:
          type: string
        prices:
          type: array
          items:
            $ref: "#/components/schemas/LatestPriceRow"

    LatestPricesWarning:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          example: unknown_market
        message:
          type: string
        market:
          type: string
          description: Marketplace slug the warning is about, when applicable.

    LatestPricesResponse:
      type: object
      required: [data, missing, warnings]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/LatestPricesItem"
        missing:
          type: array
          items:
            type: string
          description: market_hash_names with no currently available rows for the requested market filter.
        warnings:
          type: array
          items:
            $ref: "#/components/schemas/LatestPricesWarning"

    SpreadBuyLeg:
      type: object
      required: [market, market_type, price_basis, price, buyer_fee_pct, trade_lock_days, confidence]
      properties:
        market:
          type: string
        market_type:
          $ref: "#/components/schemas/MarketType"
        price_basis:
          $ref: "#/components/schemas/PriceBasis"
        price:
          type: string
        buyer_fee_pct:
          type: number
          format: double
        trade_lock_days:
          type: integer
        listings_count:
          type: integer
          nullable: true
        confidence:
          $ref: "#/components/schemas/ConfidenceTier"

    SpreadSellLeg:
      type: object
      required: [market, market_type, price_basis, price, seller_fee_pct, confidence]
      properties:
        market:
          type: string
        market_type:
          $ref: "#/components/schemas/MarketType"
        price_basis:
          $ref: "#/components/schemas/PriceBasis"
        price:
          type: string
        seller_fee_pct:
          type: number
          format: double
        listings_count:
          type: integer
          nullable: true
        confidence:
          $ref: "#/components/schemas/ConfidenceTier"

    Spread:
      type: object
      required:
        - market_hash_name
        - currency
        - buy
        - sell
        - gross_spread_pct
        - net_spread_pct
        - trade_lock_risk_pct
        - risk_adjusted_net_spread_pct
      properties:
        market_hash_name:
          type: string
        currency:
          type: string
          example: USD
        buy:
          $ref: "#/components/schemas/SpreadBuyLeg"
        sell:
          $ref: "#/components/schemas/SpreadSellLeg"
        gross_spread_pct:
          type: string
          description: '`(sell - buy) / buy × 100`, as a 2-decimal string.'
        net_spread_pct:
          type: string
          description: Gross spread after applying both marketplaces' buyer/seller fees.
        trade_lock_risk_pct:
          type: string
          description: '`trade_lock_days × 0.5%`. Coarse — see `risk_model`.'
        risk_adjusted_net_spread_pct:
          type: string
          description: '`net_spread_pct - trade_lock_risk_pct`. The field the API sorts on by default.'

    SpreadRiskModel:
      type: object
      required:
        - version
        - trade_lock_risk_per_day_pct
        - coarse
        - requires_historical_volatility
      properties:
        version:
          type: string
          example: mvp_lock_days_v1
        trade_lock_risk_per_day_pct:
          type: string
          example: "0.50"
        coarse:
          type: boolean
        requires_historical_volatility:
          type: boolean
          description: Set to true until enough price history exists for a volatility-based model.

    SpreadsResponse:
      type: object
      required: [data, risk_model]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Spread"
        next_cursor:
          type: string
        risk_model:
          $ref: "#/components/schemas/SpreadRiskModel"
