openapi: 3.0.3
info:
  title: WatchCollect Consumer Price API
  version: 1.0.0
  description: >-
    Browser-safe, read-only consumer price ranges for Watchatic. Responses are
    live projections of WatchCollect market changes. Every year/condition cell
    uses the last 30 days when at least ten inlier listings are available and
    otherwise falls back to the historical data already stored for the last 90
    days. A selected window needs at least three listings to publish. Origin validation is an
    abuse control and is not cryptographic client authentication; the contract
    deliberately exposes no market averages, samples, listings, internal
    filters, source markets, view identifiers or buy-signal data.
servers:
  - url: https://api-v2.watchcollect.com
tags:
  - name: consumer-prices
paths:
  /v2/public/price-engine/v1/prices:
    options:
      operationId: preflightConsumerPrices
      tags: [consumer-prices]
      summary: Validate a browser CORS preflight
      security: []
      parameters:
        - $ref: "#/components/parameters/Origin"
      responses:
        "204":
          description: Origin is allowed.
          headers:
            Access-Control-Allow-Origin:
              $ref: "#/components/headers/AllowedOrigin"
            Access-Control-Allow-Methods:
              schema: { type: string }
            Access-Control-Allow-Headers:
              schema: { type: string }
        "403":
          $ref: "#/components/responses/ForbiddenOrigin"
    get:
      operationId: getConsumerPrices
      tags: [consumer-prices]
      summary: Read live consumer ranges for one configured watch
      security: []
      parameters:
        - $ref: "#/components/parameters/Origin"
        - name: brand
          in: query
          required: true
          schema:
            type: string
            enum: [rolex, patek, ap]
          example: rolex
        - name: reference
          in: query
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 100
          example: 124060
        - name: configuration_key
          in: query
          required: false
          description: Exact WatchCollect configuration key when a reference has multiple published executions.
          schema:
            type: string
            minLength: 1
            maxLength: 200
        - name: If-None-Match
          in: header
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Published ranges from the latest successful live snapshot.
          headers:
            Access-Control-Allow-Origin:
              $ref: "#/components/headers/AllowedOrigin"
            Cache-Control:
              description: Short browser/CDN cache; ETag revalidation keeps reads near-live.
              schema:
                type: string
                example: public, max-age=30, stale-while-revalidate=30
            ETag:
              schema: { type: string }
            Last-Modified:
              description: RFC 7231 HTTP-date for the published snapshot.
              schema:
                type: string
                example: Thu, 27 Aug 2026 15:24:46 GMT
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConsumerPriceResponse"
        "304":
          description: The ETag still represents the current snapshot.
        "400":
          description: A required query parameter is missing or invalid.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403":
          $ref: "#/components/responses/ForbiddenOrigin"
        "404":
          description: Unknown, unpublished or deliberately indistinguishable watch configuration.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          description: Client-IP or global rate limit exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: No successful snapshot is available or the last good snapshot is older than 2 hours.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /v2/public/price-engine/v1/catalog:
    get:
      operationId: getConsumerPriceCatalog
      tags: [consumer-prices]
      summary: List every live watch option available to the webshop
      description: >-
        Returns canonical reference and configuration-key combinations backed by
        a fresh published snapshot. Omit brand to list Rolex, Patek Philippe and
        Audemars Piguet together.
      security: []
      parameters:
        - $ref: "#/components/parameters/Origin"
        - name: brand
          in: query
          required: false
          schema:
            type: string
            enum: [rolex, patek, ap]
        - name: If-None-Match
          in: header
          required: false
          schema: { type: string }
      responses:
        "200":
          description: All currently published and fresh watch options, optionally filtered by brand.
          headers:
            Access-Control-Allow-Origin:
              { $ref: "#/components/headers/AllowedOrigin" }
            Cache-Control: { schema: { type: string } }
            ETag: { schema: { type: string } }
            Last-Modified: { schema: { type: string } }
          content:
            application/json:
              schema:
                { $ref: "#/components/schemas/ConsumerPriceCatalogResponse" }
        "304": { description: The catalog ETag has not changed. }
        "400":
          description: Invalid brand slug.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403": { $ref: "#/components/responses/ForbiddenOrigin" }
        "429":
          description: Shared catalog/single-watch rate limit exceeded.
          headers:
            Retry-After: { schema: { type: integer, minimum: 1 } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: The catalog snapshot is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
  /v2/public/price-engine/v1/prices/bulk:
    get:
      operationId: getConsumerPricesByBrand
      tags: [consumer-prices]
      summary: Read every live consumer range for one brand
      description: >-
        Returns all fresh published configurations and yearly consumer ranges
        for one brand in one set-based snapshot read. This endpoint has a
        stricter 12 requests/minute per-IP limit with burst 3.
      security: []
      parameters:
        - $ref: "#/components/parameters/Origin"
        - name: brand
          in: query
          required: true
          schema:
            type: string
            enum: [rolex, patek, ap]
        - name: If-None-Match
          in: header
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Every fresh published consumer-price snapshot for the requested brand.
          headers:
            Access-Control-Allow-Origin:
              { $ref: "#/components/headers/AllowedOrigin" }
            Cache-Control: { schema: { type: string } }
            ETag: { schema: { type: string } }
            Last-Modified: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConsumerPriceBulkResponse" }
        "304": { description: The brand snapshot ETag has not changed. }
        "400":
          description: Missing or invalid brand slug.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "403": { $ref: "#/components/responses/ForbiddenOrigin" }
        "404":
          description: No published prices exist for the requested brand.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429":
          description: Bulk client-IP or global rate limit exceeded.
          headers:
            Retry-After: { schema: { type: integer, minimum: 1 } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "503":
          description: The brand snapshot is temporarily unavailable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
components:
  parameters:
    Origin:
      name: Origin
      in: header
      required: true
      description: >-
        Must exactly match https://watchatic.com, https://www.watchatic.com,
        https://docs.watchcollect.com or one explicitly configured staging
        origin. Missing origins are rejected.
      schema:
        type: string
        format: uri
      example: https://watchatic.com
  headers:
    AllowedOrigin:
      description: Exact reflected allowlisted origin; never a wildcard.
      schema:
        type: string
        format: uri
  responses:
    ForbiddenOrigin:
      description: Missing or non-allowlisted Origin.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
  schemas:
    ConsumerPriceStatus:
      type: string
      enum: [available, insufficient_data]
    ConsumerPriceCondition:
      type: object
      required: [status]
      properties:
        status:
          $ref: "#/components/schemas/ConsumerPriceStatus"
        min_eur_minor:
          type: integer
          format: int64
          minimum: 0
          nullable: true
          description: Rounded lower consumer bound in euro cents; omitted when unavailable.
        max_eur_minor:
          type: integer
          format: int64
          minimum: 0
          nullable: true
          description: Rounded upper consumer bound in euro cents; omitted when unavailable.
      additionalProperties: false
    ConsumerPriceYear:
      type: object
      required: [year, new, lightly_worn]
      properties:
        year:
          type: integer
          minimum: 1900
          maximum: 2200
          description: Calendar year; the public API never returns a year after the current calendar year.
        new:
          $ref: "#/components/schemas/ConsumerPriceCondition"
        lightly_worn:
          $ref: "#/components/schemas/ConsumerPriceCondition"
      additionalProperties: false
    ConsumerPriceResponse:
      type: object
      required: [schema_version, brand, reference, currency, as_of, years]
      properties:
        schema_version:
          type: string
          enum: [price-engine.public.v1]
        brand:
          type: string
          enum: [Rolex, Patek Philippe, Audemars Piguet]
        reference:
          type: string
        configuration_key:
          type: string
          nullable: true
        configuration_label:
          type: string
          nullable: true
        currency:
          type: string
          enum: [EUR]
        as_of:
          type: string
          format: date-time
        years:
          type: array
          description: Published calendar years up to and including the current year.
          items:
            $ref: "#/components/schemas/ConsumerPriceYear"
      additionalProperties: false
    ConsumerPriceCatalogItem:
      type: object
      required: [brand, brand_slug, reference, years]
      properties:
        brand:
          type: string
          enum: [Rolex, Patek Philippe, Audemars Piguet]
        brand_slug:
          type: string
          enum: [rolex, patek, ap]
        reference: { type: string }
        configuration_key: { type: string, nullable: true }
        configuration_label: { type: string, nullable: true }
        years:
          type: array
          description: Available calendar years up to and including the current year.
          items: { type: integer, minimum: 1900, maximum: 2200 }
      additionalProperties: false
    ConsumerPriceCatalogResponse:
      type: object
      required: [schema_version, as_of, total, items]
      properties:
        schema_version: { type: string, enum: [price-engine.catalog.v1] }
        as_of: { type: string, format: date-time }
        total: { type: integer, minimum: 0 }
        items:
          type: array
          items: { $ref: "#/components/schemas/ConsumerPriceCatalogItem" }
      additionalProperties: false
    ConsumerPriceBulkItem:
      type: object
      required: [reference, as_of, years]
      properties:
        reference: { type: string }
        configuration_key: { type: string, nullable: true }
        configuration_label: { type: string, nullable: true }
        as_of: { type: string, format: date-time }
        years:
          type: array
          items: { $ref: "#/components/schemas/ConsumerPriceYear" }
      additionalProperties: false
    ConsumerPriceBulkResponse:
      type: object
      required: [schema_version, brand, currency, as_of, total, items]
      properties:
        schema_version: { type: string, enum: [price-engine.bulk.v1] }
        brand:
          type: string
          enum: [Rolex, Patek Philippe, Audemars Piguet]
        currency: { type: string, enum: [EUR] }
        as_of: { type: string, format: date-time }
        total: { type: integer, minimum: 1 }
        items:
          type: array
          items: { $ref: "#/components/schemas/ConsumerPriceBulkItem" }
      additionalProperties: false
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            request_id:
              type: string
              nullable: true
          additionalProperties: false
      additionalProperties: false
