openapi: 3.1.0
info:
  title: "Pricto API"
  version: 1.0.0-draft.1
  summary: Current prices, price history and product identity across retailers, Chile first.
  description: |
    Pricto: current prices, sale and card prices, stock and price history for products
    across Chilean retailers (supermarkets, pharmacies, electronics, home and more), with the
    same product identified across chains and the source of every price. Early access: the API
    answers with synthetic sample data. Try every endpoint with the public sandbox key
    `sk_test_sandbox`; the remote MCP server is at `https://pricto.dev/mcp` (and `https://pricto.dev/mcp/sandbox`
    without login). Pricing: https://pricto.dev/precios and https://pricto.dev/pricing.json.

    Conventions that apply to every endpoint:

    - Auth: `Authorization: Bearer <api_key>`. Keys look like `sk_live_...` (production data)
      or `sk_test_...` (free sandbox with fixed synthetic data). The public sandbox key is
      `sk_test_sandbox`.
    - Responses are JSON objects with `data`, `meta` and, for lists, `pagination`.
    - Lists use cursor pagination: pass `pagination.next_cursor` back as `cursor`.
    - Errors use RFC 9457 `application/problem+json` with a stable `code` and an actionable `hint`.
    - `lang` (`es` | `en`) sets the language of names, labels and errors. Original retailer
      titles are always returned untranslated in `title_original`.
    - `currency` (`local` | ISO 4217 code such as `USD`) adds a `converted` block to every price.
      Amounts are always returned in the source currency too; conversions show the rate and date used.
    - Billing: you pay per data item
      delivered (1 credit per offer price, 1 per search page of up to 20, 1 per 30 history points
      per series); empty results and reference data cost 0; reading the same observation again
      within 24 hours costs 0 (deduplicated). The cost of each operation is in `x-credits`.
    - Every paid operation accepts `max_credits` (results are cut to fit, cheapest or most relevant
      first, with `meta.usage.truncated: true`) and `dry_run=true` (returns `data: null` and the
      quote in `meta.usage`, without charging). POST operations take both in the JSON body.
    - Every data response carries `meta.attribution.text`, a source line to quote as is
      (brand, capture time in the country time zone, product or offer ID).
    - Every response reports `meta.credits_charged`, `meta.credits_deduplicated` and `meta.usage`,
      and the headers `X-Credits-Charged`, `X-Credits-Deduplicated`, `X-Credits-Remaining`,
      `X-Credits-Period-Reset` and `X-Request-Id`.
    - Times are ISO 8601 in UTC. Dates are `YYYY-MM-DD`.
    - IDs are opaque strings with a type prefix (`prd_`, `ofr_`, `str_`, `exp_`). Retailer and
      category IDs are readable slugs (`cl-jumbo`, `food.dairy.milk`).
  contact:
    name: "Pricto"
    url: "https://pricto.dev/"

  license:
    name: Proprietary
servers:
  - url: "https://pricto.dev/v1"
    description: Production (early access; the public sandbox key returns synthetic sample data)
  - url: "https://mercali-platform.vercel.app/v1"
    description: Same service on the previous host, kept for existing clients
  - url: http://localhost:8787/v1
    description: Local scaffold with synthetic sample data
security:
  - apiKey: []
tags:
  - name: Products
    description: Canonical products (the same item identified across retailers).
  - name: Prices
    description: Current prices, comparisons and history.
  - name: Offers
    description: Listings as published by each retailer, matched or not to a product.
  - name: Reference
    description: Retailers, stores, categories, brands, countries, FX rates and coverage. Free.
  - name: Bulk
    description: Asynchronous exports in CSV, JSONL or Parquet.
  - name: Account
    description: Plan, credit balance, usage and custom requests.

paths:
  /products/search:
    get:
      tags: [Products]
      operationId: searchProducts
      summary: Search products by text
      description: |
        Hybrid text and semantic search over canonical products. Accepts natural queries such as
        "leche entera 1 litro" or a GTIN. Each result includes a price summary across retailers
        so a single call is often enough to answer "how much does X cost".
      x-credits: '1 per page of up to 20 results; 0 when nothing is found'
      parameters:
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - name: q
          in: query
          required: true
          description: Free text or a GTIN/EAN.
          schema: { type: string, minLength: 1, maxLength: 200 }
        - $ref: '#/components/parameters/country'
        - $ref: '#/components/parameters/categoryFilter'
        - $ref: '#/components/parameters/brandFilter'
        - $ref: '#/components/parameters/retailerFilter'
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
        - $ref: '#/components/parameters/limitSmall'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Matching products ranked by relevance.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSearchList'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /products:
    get:
      tags: [Products]
      operationId: listProducts
      summary: List products by exact filters
      description: Filter the catalog by identifiers, brand or category. Use `/products/search` for free text.
      x-credits: '1 per 20 products returned'
      parameters:
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - name: gtin
          in: query
          description: One or more GTIN/EAN codes, comma separated (max 100).
          schema: { type: string }
        - $ref: '#/components/parameters/country'
        - $ref: '#/components/parameters/categoryFilter'
        - $ref: '#/components/parameters/brandFilter'
        - $ref: '#/components/parameters/retailerFilter'
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/format'
      responses:
        '200':
          description: Products.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductSearchList' }
            text/csv:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /products/{product_id}:
    get:
      tags: [Products]
      operationId: getProduct
      summary: Get a product
      x-credits: 1
      parameters:
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - $ref: '#/components/parameters/productId'
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
      responses:
        '200':
          description: The canonical product with identifiers, attributes and price summary.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Product' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /products/{product_id}/prices:
    get:
      tags: [Prices]
      operationId: getProductPrices
      summary: Current prices of a product at every retailer
      description: |
        One row per retailer offer matched to the product: in stock first, then by `price` ascending.
        Use `as_of` to get the prices that were in force on a past date.
      x-credits: '1 per retailer offer returned'
      parameters:
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - $ref: '#/components/parameters/productId'
        - $ref: '#/components/parameters/retailerFilter'
        - $ref: '#/components/parameters/location'
        - $ref: '#/components/parameters/asOf'
        - name: include_unavailable
          in: query
          description: Include offers that are out of stock.
          schema: { type: boolean, default: true }
        - $ref: '#/components/parameters/minConfidence'
        - $ref: '#/components/parameters/matchKinds'
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
      responses:
        '200':
          description: Prices by retailer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductPrices' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /products/{product_id}/history:
    get:
      tags: [Prices]
      operationId: getProductHistory
      summary: Price history of a product
      description: |
        Time series per retailer. With `interval=day` (default) each point is the last price
        observed that day. `interval=raw` returns every observation. The response includes a
        summary (min, max, change) per series so agents rarely need to process the points.
      x-credits: '1 per 30 points per series (minimum 1 per series)'
      parameters:
        - $ref: '#/components/parameters/fxDate'
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - $ref: '#/components/parameters/productId'
        - $ref: '#/components/parameters/retailerFilter'
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/interval'
        - $ref: '#/components/parameters/minConfidence'
        - $ref: '#/components/parameters/matchKinds'
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
      responses:
        '200':
          description: History series.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PriceHistory' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/PlanLimit' }
        '404': { $ref: '#/components/responses/NotFound' }

  /prices/compare:
    post:
      tags: [Prices]
      operationId: comparePrices
      summary: Compare a basket of products across retailers
      description: |
        Matrix of current prices for up to 50 products (by `product_id` or `gtin`) at each retailer,
        plus the basket total per retailer counting only retailers that have every item.
      x-credits: '1 per price in the matrix (product × retailer with data)'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CompareRequest' }
      parameters:
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
      responses:
        '200':
          description: Price matrix and basket totals.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Comparison' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }

  /prices/batch:
    post:
      tags: [Prices]
      operationId: batchPrices
      summary: Current prices for many products or offers in one call
      description: For integrations that refresh a list of SKUs. Up to 500 items.
      x-credits: '1 per offer price returned; items not found cost 0'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BatchRequest' }
      parameters:
        - $ref: '#/components/parameters/currency'
      responses:
        '200':
          description: One result per requested item, in the same order.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BatchResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /offers:
    get:
      tags: [Offers]
      operationId: listOffers
      summary: List retailer offers
      description: |
        Listings as published by retailers. Filter by retailer, category, promotion or recent
        change. `changed_since` returns offers whose price or availability changed after a time,
        which is the cheapest way to keep a mirror up to date.
      x-credits: '1 per offer returned'
      parameters:
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - $ref: '#/components/parameters/retailerFilter'
        - $ref: '#/components/parameters/country'
        - $ref: '#/components/parameters/categoryFilter'
        - $ref: '#/components/parameters/brandFilter'
        - name: product_id
          in: query
          schema: { type: string }
        - name: gtin
          in: query
          schema: { type: string }
        - name: on_promotion
          in: query
          description: Only offers with a sale price, card price or promotion.
          schema: { type: boolean }
        - name: changed_since
          in: query
          schema: { type: string, format: date-time }
        - name: matched
          in: query
          description: Filter by whether the offer is matched to a canonical product.
          schema: { type: boolean }
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/format'
      responses:
        '200':
          description: Offers.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfferList' }
            text/csv:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /offers/{offer_id}:
    get:
      tags: [Offers]
      operationId: getOffer
      summary: Get an offer with its current price
      x-credits: 1
      parameters:
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - name: offer_id
          in: path
          required: true
          schema: { type: string, pattern: '^ofr_' }
        - $ref: '#/components/parameters/lang'
        - $ref: '#/components/parameters/currency'
      responses:
        '200':
          description: The offer.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Offer' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /offers/{offer_id}/history:
    get:
      tags: [Offers]
      operationId: getOfferHistory
      summary: Observations of a single offer
      x-credits: '1 per 30 points (minimum 1)'
      parameters:
        - $ref: '#/components/parameters/fxDate'
        - $ref: '#/components/parameters/maxCredits'
        - $ref: '#/components/parameters/dryRun'
        - name: offer_id
          in: path
          required: true
          schema: { type: string, pattern: '^ofr_' }
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - $ref: '#/components/parameters/interval'
        - $ref: '#/components/parameters/currency'
      responses:
        '200':
          description: History of one offer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PriceHistory' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /live-checks:
    post:
      tags: [Prices]
      operationId: createLiveCheck
      summary: Request a live price check (planned, phase F3)
      description: |
        Captures the offer right now instead of returning the last scheduled capture. Asynchronous:
        returns a job that completes in seconds to minutes. Not available in the scaffold (returns 501).
      x-credits: '10 per offer refreshed'
      x-status: planned
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [offer_ids]
              properties:
                offer_ids:
                  type: array
                  maxItems: 20
                  items: { type: string }
      responses:
        '202':
          description: Job accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Job' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '501': { $ref: '#/components/responses/NotImplemented' }

  /exports:
    post:
      tags: [Bulk]
      operationId: createExport
      summary: Create a bulk export
      description: |
        Exports offers, products or observations matching the filters as CSV, JSONL or Parquet.
        Returns a job; when `status` is `succeeded`, `files` contains signed download URLs valid
        for 24 hours. Planned for phase F1; the scaffold returns 501.
      x-credits: '1 per 10 rows'
      x-status: planned
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string, maxLength: 128 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExportRequest' }
      responses:
        '202':
          description: Export job accepted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Job' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '501': { $ref: '#/components/responses/NotImplemented' }

  /exports/{export_id}:
    get:
      tags: [Bulk]
      operationId: getExport
      summary: Get an export job
      x-credits: 0
      x-status: planned
      parameters:
        - name: export_id
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Export job.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Job' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '501': { $ref: '#/components/responses/NotImplemented' }

  /retailers:
    get:
      tags: [Reference]
      operationId: listRetailers
      summary: List retailers
      x-credits: 0
      parameters:
        - $ref: '#/components/parameters/country'
        - name: segment
          in: query
          schema: { $ref: '#/components/schemas/Segment' }
        - $ref: '#/components/parameters/lang'
      responses:
        '200':
          description: Retailers with their coverage.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Retailer' }
                  meta: { $ref: '#/components/schemas/Meta' }

  /retailers/{retailer_id}:
    get:
      tags: [Reference]
      operationId: getRetailer
      summary: Get a retailer
      x-credits: 0
      parameters:
        - name: retailer_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/lang'
      responses:
        '200':
          description: The retailer.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Retailer' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '404': { $ref: '#/components/responses/NotFound' }

  /stores:
    get:
      tags: [Reference]
      operationId: listStores
      summary: List physical stores and pickup points
      description: Used to resolve `location` for retailers whose prices vary by store or area.
      x-credits: 0
      parameters:
        - $ref: '#/components/parameters/retailerFilter'
        - $ref: '#/components/parameters/country'
        - name: admin_area
          in: query
          description: Administrative area code, for Chile the comuna CUT code (e.g. `13114`).
          schema: { type: string }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Stores.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Store' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
                  meta: { $ref: '#/components/schemas/Meta' }

  /categories:
    get:
      tags: [Reference]
      operationId: listCategories
      summary: Category taxonomy
      description: Flat list of categories; rebuild the tree with `parent_id`. Includes mappings to Google Product Taxonomy.
      x-credits: 0
      parameters:
        - name: parent_id
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/lang'
      responses:
        '200':
          description: Categories.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Category' }
                  meta: { $ref: '#/components/schemas/Meta' }

  /brands:
    get:
      tags: [Reference]
      operationId: listBrands
      summary: List brands
      x-credits: 0
      parameters:
        - name: q
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Brands.
          content:
            application/json:
              schema:
                type: object
                required: [data, pagination, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Brand' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
                  meta: { $ref: '#/components/schemas/Meta' }

  /coverage:
    get:
      tags: [Reference]
      operationId: getCoverage
      summary: What data is available
      description: |
        Counts, freshness and history depth by country, retailer and category. Agents should call
        this when a search returns nothing, to tell the user whether the data does not exist or
        the query was wrong.
      x-credits: 0
      parameters:
        - $ref: '#/components/parameters/country'
        - $ref: '#/components/parameters/retailerFilter'
        - $ref: '#/components/parameters/categoryFilter'
        - $ref: '#/components/parameters/lang'
      responses:
        '200':
          description: Coverage.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Coverage' }

  /countries:
    get:
      tags: [Reference]
      operationId: listCountries
      summary: Countries, currencies and languages
      x-credits: 0
      parameters:
        - $ref: '#/components/parameters/lang'
      responses:
        '200':
          description: Countries.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Country' }
                  meta: { $ref: '#/components/schemas/Meta' }

  /fx:
    get:
      tags: [Reference]
      operationId: getFxRate
      summary: Exchange rate used for conversions
      x-credits: 0
      parameters:
        - name: base
          in: query
          required: true
          schema: { type: string, example: CLP }
        - name: quote
          in: query
          required: true
          schema: { type: string, example: USD }
        - name: date
          in: query
          schema: { type: string, format: date }
      responses:
        '200':
          description: The rate.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/FxRate' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '404': { $ref: '#/components/responses/NotFound' }

  /account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Plan and credit balance of the calling key
      x-credits: 0
      responses:
        '200':
          description: Account.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/Account' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /usage:
    get:
      tags: [Account]
      operationId: getUsage
      summary: Credits consumed, grouped by day, operation, channel or key
      x-credits: 0
      parameters:
        - $ref: '#/components/parameters/from'
        - $ref: '#/components/parameters/to'
        - name: group_by
          in: query
          schema:
            type: string
            enum: [day, operation, channel, key]
            default: day
      responses:
        '200':
          description: Usage rows.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/UsageRow' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /data-requests:
    post:
      tags: [Account]
      operationId: createDataRequest
      summary: Ask for coverage or data that is not available
      description: |
        A retailer, country, category, frequency or delivery format that is not covered. An agent
        reviews the request, estimates feasibility and price, and replies by email. Agents may call
        this on behalf of the user only after the user agrees.
      x-credits: 0
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/DataRequestInput' }
      responses:
        '201':
          description: Request registered.
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data: { $ref: '#/components/schemas/DataRequest' }
                  meta: { $ref: '#/components/schemas/Meta' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /status:
    get:
      tags: [Reference]
      operationId: getStatus
      summary: Service status and data freshness
      security: []
      x-credits: 0
      responses:
        '200':
          description: Status.
          content:
            application/json:
              schema:
                type: object
                required: [status, version, data_as_of]
                properties:
                  status: { type: string, enum: [ok, degraded, down] }
                  version: { type: string }
                  data_as_of: { type: string, format: date-time }

  /public/coverage:
    get:
      tags: [Reference]
      operationId: getPublicCoverage
      summary: Aggregated coverage without prices (no key)
      description: Stores, offers and last capture per store, for the website and status page. No prices, products or URLs. `synthetic` is true when the service runs on sample data only.
      security: []
      x-credits: 0
      responses:
        '200':
          description: Coverage.
          content:
            application/json:
              schema:
                type: object
                required: [synthetic, data_as_of, totals, retailers]
                properties:
                  synthetic: { type: boolean }
                  data_as_of: { type: string, format: date-time }
                  totals:
                    type: object
                    required: [countries, retailers, products, offers, observations, history_since]
                    properties:
                      countries: { type: integer }
                      retailers: { type: integer }
                      products: { type: integer }
                      offers: { type: integer }
                      observations: { type: integer, description: Approximate. }
                      history_since: { type: [string, 'null'], format: date }
                  retailers:
                    type: array
                    items:
                      type: object
                      required: [id, name, country, offers, last_observed, history_since]
                      properties:
                        id: { type: string }
                        name: { type: string }
                        country: { type: string }
                        offers: { type: integer }
                        last_observed: { type: [string, 'null'], format: date-time }
                        history_since: { type: [string, 'null'], format: date }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: API key from the console (`sk_live_...` or `sk_test_...`).

  parameters:
    productId:
      name: product_id
      in: path
      required: true
      description: Canonical product ID (`prd_...`). A GTIN is also accepted here and resolved.
      schema: { type: string }
    country:
      name: country
      in: query
      description: ISO 3166-1 alpha-2 country code. Defaults to all covered countries.
      schema: { type: string, pattern: '^[A-Z]{2}$', example: CL }
    retailerFilter:
      name: retailer
      in: query
      description: One or more retailer IDs, comma separated (e.g. `cl-jumbo,cl-unimarc`).
      schema: { type: string }
    categoryFilter:
      name: category
      in: query
      description: Category ID; includes subcategories (e.g. `food.dairy`).
      schema: { type: string }
    brandFilter:
      name: brand
      in: query
      description: Brand ID or name.
      schema: { type: string }
    location:
      name: location
      in: query
      description: |
        Store ID (`str_...`), administrative area as `CL:13114` (country and comuna CUT code) or a
        place name ("Las Condes"). Only affects retailers whose prices vary by location; the
        resolved value is echoed in `meta.location`.
      schema: { type: string }
    asOf:
      name: as_of
      in: query
      description: Return the prices that were in force at the end of this date instead of the latest.
      schema: { type: string, format: date }
    from:
      name: from
      in: query
      description: Start date, inclusive. Defaults to 90 days before `to`.
      schema: { type: string, format: date }
    to:
      name: to
      in: query
      description: End date, inclusive. Defaults to today.
      schema: { type: string, format: date }
    interval:
      name: interval
      in: query
      schema:
        type: string
        enum: [raw, day, week, month]
        default: day
    lang:
      name: lang
      in: query
      description: |
        Response language as a BCP 47 tag (`es`, `es-CL`, `en`, `en-US`...). Supported today: es, en.
        An unsupported language falls back to English with a warning, never an error. Falls back to
        `Accept-Language`, then to the account default. The resolved value is in `meta.lang`.
      schema: { type: string, example: en }
    fxDate:
      name: fx_date
      in: query
      description: Convert every amount with the rate of this date instead of the rate of each observation's own date.
      schema: { type: string, format: date }
    currency:
      name: currency
      in: query
      description: Add converted amounts in this ISO 4217 currency. `local` (default) returns source currency only.
      schema: { type: string, default: local, example: USD }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 50 }
    limitSmall:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 20, default: 10 }
    minConfidence:
      name: min_confidence
      in: query
      description: 'Only include offers whose match confidence is at least this value. Default 0.85 (decision E8): below it an offer is not grouped with the product automatically.'
      schema: { type: number, minimum: 0, maximum: 1, default: 0.85 }
    matchKinds:
      name: match_kinds
      in: query
      description: |
        Match kinds to include, comma separated. Default `identical`. Add `pack` for offers that are N units
        of the product (compare by unit price, see `match.pack_ratio`) and `equivalent` for declared
        substitutes (another brand or lab), which are not the same item.
      schema: { type: string, default: identical, example: 'identical,pack' }
    maxCredits:
      name: max_credits
      in: query
      description: Spend at most this many credits on this call. Results are cut to fit and `meta.usage.truncated` is true.
      schema: { type: number, minimum: 0 }
    dryRun:
      name: dry_run
      in: query
      description: 'Return the quote in `meta.usage` with `data` set to null, without charging.'
      schema: { type: boolean, default: false }
    cursor:
      name: cursor
      in: query
      description: Opaque cursor from `pagination.next_cursor`.
      schema: { type: string }
    format:
      name: format
      in: query
      description: '`json` (default) or `csv`. Equivalent to `Accept: text/csv`.'
      schema:
        type: string
        enum: [json, csv]
        default: json

  responses:
    BadRequest:
      description: Invalid parameter.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    InsufficientCredits:
      description: Not enough credits for even one item (`insufficient_credits`) or the monthly spend cap was reached (`spend_cap_reached`).
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    PlanLimit:
      description: The plan does not include this (`plan_required`), for example history older than the plan window.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: Resource not found.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: Too many requests. Retry after the number of seconds in `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotImplemented:
      description: Planned, not available yet.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Usage:
      type: object
      description: What this call cost and why.
      required: [credits_charged, credits_deduplicated, truncated, dry_run, units]
      properties:
        credits_charged: { type: number }
        credits_deduplicated: { type: number, description: 'Credits not charged because the same data was already paid for in the last 24 hours.' }
        truncated: { type: boolean, description: 'True when max_credits, the balance, the monthly spend cap or the plan history window cut the result.' }
        truncated_reason: { type: [string, 'null'], enum: [max_credits, balance, spend_cap, plan_history_window, null] }
        dry_run: { type: boolean }
        units:
          type: object
          properties:
            offers: { type: integer }
            history_points: { type: integer }
            search_pages: { type: integer }
            items: { type: integer }

    Attribution:
      type: object
      description: |
        Source line an agent can quote as is, for example
        "Fuente: <brand> · 23-10-2026 08:00 CLST · prd_01colu" or
        "Source: <brand> · Oct 23, 2026 08:00 CLST · prd_01colu". Time of the most recent observation
        in the response, in the country time zone. Present on every data response. When the response is
        about one offer it carries the full provenance: retailer, capture method, time, URL and ID
        ("Fuente: <brand> · Jumbo, página pública, 23-10-2026 08:00 CLST · jumbo.cl/… · ofr_…").
      required: [text, source, as_of, resource_id, url]
      properties:
        text: { type: string }
        source: { type: string, description: Brand name. }
        as_of: { type: string, format: date-time }
        resource_id: { type: [string, 'null'], description: Product or offer the response is about, if one. }
        url: { type: [string, 'null'], format: uri, description: Public page for the product, when it exists. }
        capture_method: { type: [string, 'null'], enum: [public_page, public_api, retailer_feed, open_data, null], description: 'Set when the response is about one offer.' }
        source_url: { type: [string, 'null'], format: uri, description: 'Retailer URL of the offer, when the response is about one offer.' }

    Meta:
      type: object
      required: [request_id, credits_charged, credits_deduplicated, usage]
      properties:
        request_id: { type: string }
        attribution: { $ref: '#/components/schemas/Attribution' }
        credits_charged: { type: number }
        credits_deduplicated: { type: number }
        credits_remaining: { type: [number, 'null'] }
        period_resets_at: { type: [string, 'null'], format: date-time }
        usage: { $ref: '#/components/schemas/Usage' }
        lang: { type: string, description: 'Resolved response language (es or en today; open set).' }
        query_lang: { type: [string, 'null'], description: 'Language detected in the search query (search only).' }
        currency: { type: string }
        fx_policy:
          type: [string, 'null']
          enum: [point_date, fixed, null]
          description: '`point_date`: each amount converted with the rate of its own date; `fixed`: one rate (fx_date) for all.' 
        data_as_of:
          type: string
          format: date-time
          description: Time of the most recent observation included in the response.
        location:
          type: [object, 'null']
          properties:
            input: { type: string }
            resolved: { type: string }
            label: { type: string }
        warnings:
          type: array
          description: Non-fatal notes the caller (or the agent) should consider, e.g. a retailer excluded for low match confidence.
          items: { type: string }

    Pagination:
      type: object
      required: [has_more, next_cursor]
      properties:
        has_more: { type: boolean }
        next_cursor: { type: [string, 'null'] }

    Problem:
      type: object
      description: RFC 9457 problem details.
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        code:
          type: string
          enum:
            - invalid_parameter
            - unauthorized
            - insufficient_credits
            - spend_cap_reached
            - plan_required
            - not_found
            - rate_limited
            - not_implemented
            - coverage_unavailable
            - internal_error
            - service_unavailable
        detail: { type: string }
        hint:
          type: string
          description: What to do next, written so an agent can act on it.
        param: { type: string, description: 'Offending parameter, if any.' }
        request_id: { type: string }
        docs_url: { type: string, format: uri }
        balance: { type: number, description: 'insufficient_credits only.' }
        credits_needed: { type: number, description: 'insufficient_credits only.' }
        usd_needed: { type: number, description: 'insufficient_credits only.' }
        top_up_url: { type: string, format: uri, description: 'insufficient_credits only.' }
        pricing:
          type: object
          description: 'insufficient_credits only. Machine-readable prices so an agent can ask its human for a top-up (and, later, pay through x402 or MPP).'
          properties:
            currency: { type: string }
            usd_per_1000_credits: { type: number }
            min_top_up_usd: { type: number }
            credits_per_min_top_up: { type: integer }
            prepaid_validity_months: { type: integer }
            plans: { type: array, items: { type: object, properties: { id: { type: string }, usd_per_month: { type: number }, credits_per_month: { type: integer } } } }
            top_up_url: { type: string, format: uri }

    Segment:
      type: string
      enum: [supermarket, pharmacy, electronics, home_improvement, department_store, marketplace, fashion, other]

    Size:
      type: object
      description: Net content as sold.
      required: [value, unit, text]
      properties:
        value: { type: number }
        unit: { type: string, enum: [g, kg, ml, l, unit, m, cm, m2, sheet, wash] }
        pack_count: { type: integer, minimum: 1, description: 'Units per pack (6 for a six-pack).' }
        text: { type: string, example: 1 L }

    Converted:
      type: object
      required: [currency, price, fx_rate, fx_date]
      properties:
        currency: { type: string }
        price: { type: number }
        list_price: { type: [number, 'null'] }
        card_price: { type: [number, 'null'] }
        unit_price: { type: [number, 'null'] }
        fx_rate: { type: number, description: 'Units of target currency per unit of source currency.' }
        fx_date: { type: string, format: date, description: 'Publication date of the rate used (last published on or before the observation date).' }
        fx_source: { type: string, example: bcch-dolar-observado }
        fx_method: { type: string, enum: [direct, cross] }

    Installments:
      type: object
      required: [count, interest_free]
      properties:
        count: { type: integer, minimum: 2 }
        interest_free: { type: boolean }
        requires_card: { type: [string, 'null'] }

    ConditionalPrice:
      type: object
      required: [kind, label, price]
      properties:
        kind: { type: string, enum: [payment_method, membership, app_only, coupon, other] }
        label: { type: string, description: As shown by the retailer. }
        price: { type: number }
        payment_method: { type: [string, 'null'], example: unipay }
        membership_tier: { type: [string, 'null'], example: club-unimarc }
        installments_count: { type: [integer, 'null'] }
        interest_free: { type: [boolean, 'null'] }

    Promotion:
      type: object
      required: [type, label]
      properties:
        type: { type: string, enum: [multi_buy, nth_unit, percent_off, amount_off, bundle, gift, other] }
        label: { type: string, description: 'As shown by the retailer, e.g. "2 x $2.990".' }
        min_quantity: { type: [integer, 'null'] }
        effective_unit_price: { type: [number, 'null'], description: 'Price per item when the promotion applies.' }
        requires_card: { type: [string, 'null'], description: 'Payment method required, if any.' }
        valid_until: { type: [string, 'null'], format: date }

    PriceQuote:
      type: object
      description: |
        A price as observed. `price` is the best price any customer pays without conditions
        (sale price if present, otherwise list price). Conditional prices are separate:
        `card_price` requires a payment method and `promotions` require a quantity or bundle.
      required: [currency, price, list_price, availability, observed_at, capture_method]
      properties:
        currency: { type: string, example: CLP }
        price: { type: number }
        list_price: { type: number }
        sale_price: { type: [number, 'null'] }
        card_price: { type: [number, 'null'], description: 'Convenience: the lowest price in conditional_prices that requires a payment method or membership.' }
        card_label: { type: [string, 'null'], example: Tarjeta Cencosud }
        conditional_prices:
          type: array
          description: Every price that requires a condition other than quantity (payment method, loyalty tier, app, coupon), as published.
          items: { $ref: '#/components/schemas/ConditionalPrice' }
        unit_price:
          type: [object, 'null']
          required: [amount, per]
          properties:
            amount: { type: number }
            per: { type: string, enum: [kg, l, unit, m, m2] }
        promotions:
          type: array
          items: { $ref: '#/components/schemas/Promotion' }
        availability: { type: string, enum: [in_stock, out_of_stock, limited, unknown] }
        tax_included: { type: boolean, default: true, description: As shown by the retailer (observed). }
        tax_rate: { type: [number, 'null'], description: 'Derived. Main VAT rate for this country and category, null when not known.' }
        price_ex_tax: { type: [number, 'null'], description: 'Derived from price and tax_rate. Null when tax_rate is null.' }
        tax_rule_id: { type: [string, 'null'], description: 'Versioned rule used to derive tax_rate.' }
        other_taxes_included: { type: [boolean, 'null'], description: 'True when other taxes remain in price_ex_tax (e.g. Chile IABA on sweetened drinks).' }
        installments:
          oneOf: [{ $ref: '#/components/schemas/Installments' }, { type: 'null' }]
        observed_at: { type: string, format: date-time }
        observed_local_date: { type: string, format: date, description: Capture date in the retailer time zone. Daily series use this date. }
        capture_method:
          type: string
          enum: [public_page, public_api, retailer_feed, open_data]
          description: |
            How the price was obtained, part of the provenance with `url` and `observed_at`.
            `public_page`: read from the retailer's public web page (HTML, JSON-LD or the page payload).
            `public_api`: an open, unauthenticated JSON endpoint of the retailer's own site.
            `retailer_feed`: delivered by the retailer under an agreement.
            `open_data`: a public dataset published by a government or third party.
        quality_status: { type: string, enum: [ok, warning], description: '`warning` when a validation flagged the observation (price jump, zero, unusual unit). Quarantined observations are never served.' }
        quality_flags:
          type: array
          description: |
            Why the price was flagged. The price is delivered as published, never corrected.
            `discount_over_85`: more than 85% off the list price. `round_price_low`: a round price of CLP 1,000 or less.
            Both often mean a placeholder price or a store error.
          items: { type: string, enum: [discount_over_85, round_price_low, price_jump, zero_price, unit_mismatch] }
        corrected: { type: boolean, description: True when the observation was amended after capture (the original is kept). }
        converted: { $ref: '#/components/schemas/Converted' }

    Match:
      type: object
      description: How the offer was linked to the canonical product.
      required: [method, confidence, kind]
      properties:
        method: { type: string, enum: [gtin, rules, model, manual, singleton], description: "`singleton`: the offer matched nothing else and is its own product." }
        confidence: { type: number, minimum: 0, maximum: 1 }
        kind:
          type: string
          enum: [identical, pack, equivalent]
          description: '`identical`: the same item. `pack`: the offer is N units of the product (`pack_ratio`), comparable by unit price. `equivalent`: a declared substitute of another brand or lab, not the same item.'
        pack_ratio: { type: [number, 'null'], description: 'Units of the product in the offer, for kind pack.' }

    BrandRef:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }

    CategoryRef:
      type: object
      required: [id, label]
      properties:
        id: { type: string }
        label: { type: string }
        path: { type: array, items: { type: string } }

    RetailerRef:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }

    PriceSummary:
      type: object
      required: [currency, min, max, offers_count, retailers_count]
      properties:
        currency: { type: string }
        min: { type: [number, 'null'] }
        max: { type: [number, 'null'] }
        median: { type: [number, 'null'] }
        offers_count: { type: integer }
        retailers_count: { type: integer }
        best:
          type: [object, 'null']
          properties:
            retailer: { $ref: '#/components/schemas/RetailerRef' }
            offer_id: { type: string }
            price: { type: number }
        observed_at: { type: [string, 'null'], format: date-time }
        converted:
          type: object
          properties:
            currency: { type: string }
            min: { type: [number, 'null'] }
            max: { type: [number, 'null'] }
            fx_rate: { type: number }
            fx_date: { type: string, format: date }
            fx_source: { type: string }
            fx_method: { type: string, enum: [direct, cross] }

    ProductSummary:
      type: object
      required: [id, name, brand, category, price_summary]
      properties:
        id: { type: string, example: prd_8f3k2m }
        name: { type: string, example: Leche entera Colun 1 L }
        brand: { $ref: '#/components/schemas/BrandRef' }
        category: { $ref: '#/components/schemas/CategoryRef' }
        size: { oneOf: [{ $ref: '#/components/schemas/Size' }, { type: 'null' }] }
        gtin: { type: array, items: { type: string } }
        image_url: { type: [string, 'null'] }
        price_summary: { $ref: '#/components/schemas/PriceSummary' }
        score: { type: number, description: 'Relevance score, search only.' }

    Product:
      allOf:
        - $ref: '#/components/schemas/ProductSummary'
        - type: object
          properties:
            names:
              type: object
              description: Name in every available language.
              additionalProperties: { type: string }
            names_meta:
              type: object
              description: Provenance of each name, keyed by language.
              additionalProperties:
                type: object
                properties:
                  source: { type: string, enum: [retailer, canonical, model, manual] }
                  model: { type: [string, 'null'] }
                  translated_at: { type: [string, 'null'], format: date-time }
            private_label: { type: boolean, description: Store brand of one retailer. }
            relations:
              type: array
              description: Links to other canonical products.
              items:
                type: object
                required: [kind, product_id, quantity_ratio]
                properties:
                  kind: { type: string, enum: [pack_of, size_of, variant_of, equivalent, replaced_by] }
                  product_id: { type: string }
                  quantity_ratio: { type: [number, 'null'], description: 'For pack_of and size_of, how many units of product_id this product contains.' }
            identifiers:
              type: object
              properties:
                gtin: { type: array, items: { type: string } }
                mpn: { type: [string, 'null'] }
            attributes:
              type: object
              additionalProperties: true
            countries: { type: array, items: { type: string } }
            first_seen: { type: string, format: date-time }

    ProductSearchList:
      type: object
      required: [data, pagination, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/ProductSummary' }
        pagination: { $ref: '#/components/schemas/Pagination' }
        meta: { $ref: '#/components/schemas/Meta' }

    Seller:
      type: object
      description: Who sells the offer. On marketplaces it is a third party; `name` is null when the seller is a natural person (no personal data).
      required: [id, name, is_retailer_itself]
      properties:
        id: { type: string }
        name: { type: [string, 'null'] }
        is_retailer_itself: { type: boolean }

    RetailerPrice:
      allOf:
        - $ref: '#/components/schemas/PriceQuote'
        - type: object
          required: [retailer, offer_id, url, title_original, match]
          properties:
            retailer: { $ref: '#/components/schemas/RetailerRef' }
            offer_id: { type: string }
            store_id: { type: [string, 'null'], description: 'Null when the price is national/online.' }
            url: { type: string, format: uri }
            title_original: { type: string }
            seller: { $ref: '#/components/schemas/Seller' }
            match: { $ref: '#/components/schemas/Match' }

    ProductPrices:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [product, prices, summary]
          properties:
            product: { $ref: '#/components/schemas/ProductSummary' }
            prices:
              type: array
              items: { $ref: '#/components/schemas/RetailerPrice' }
            summary: { $ref: '#/components/schemas/PriceSummary' }
        meta: { $ref: '#/components/schemas/Meta' }

    HistoryPoint:
      type: object
      required: [t, price, list_price, availability]
      properties:
        t: { type: string, description: 'Date (`day`, `week`, `month` start) or timestamp (`raw`).' }
        price: { type: number }
        list_price: { type: number }
        sale_price: { type: [number, 'null'] }
        card_price: { type: [number, 'null'] }
        availability: { type: string, enum: [in_stock, out_of_stock, limited, unknown] }
        converted:
          type: object
          description: Present when `currency` is set. Converted with the rate of the point date (or fx_date).
          properties:
            price: { type: number }
            list_price: { type: number }
            fx_rate: { type: number }
            fx_date: { type: string, format: date }

    SeriesSummary:
      type: object
      required: [first, last, min, max, change_pct, changes]
      properties:
        first: { type: [number, 'null'] }
        last: { type: [number, 'null'] }
        min: { type: [number, 'null'] }
        max: { type: [number, 'null'] }
        change_pct: { type: [number, 'null'], description: '(last - first) / first × 100 in local currency, rounded to 1 decimal.' }
        converted:
          type: object
          description: Summary in the requested currency, each point at its own rate, so the change includes FX.
          properties:
            first: { type: [number, 'null'] }
            last: { type: [number, 'null'] }
            min: { type: [number, 'null'] }
            max: { type: [number, 'null'] }
            change_pct: { type: [number, 'null'] }
        changes: { type: integer, description: 'Number of times `price` changed in the period.' }
        days_on_promotion: { type: integer }

    PriceHistory:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [interval, from, to, currency, series]
          properties:
            product_id: { type: [string, 'null'] }
            interval: { type: string }
            from: { type: string, format: date }
            to: { type: string, format: date }
            currency: { type: string, description: Currency of points (always the local currency). }
            converted_currency: { type: [string, 'null'] }
            fx_policy: { type: [string, 'null'], enum: [point_date, fixed, null] }
            series:
              type: array
              items:
                type: object
                required: [retailer, offer_id, match, points, summary]
                properties:
                  retailer: { $ref: '#/components/schemas/RetailerRef' }
                  offer_id: { type: string }
                  match:
                    anyOf: [{ $ref: '#/components/schemas/Match' }, { type: 'null' }]
                    description: 'Same offers as /prices (default identical, confidence >= 0.85). With match_kinds=identical,pack a pack series has kind pack and pack_ratio: points are the pack price, divide by pack_ratio for the unit price. null only in the history of an offer not linked to a product.'

                  points:
                    type: array
                    items: { $ref: '#/components/schemas/HistoryPoint' }
                  summary: { $ref: '#/components/schemas/SeriesSummary' }
        meta: { $ref: '#/components/schemas/Meta' }

    ItemRef:
      type: object
      description: Exactly one of `product_id`, `gtin` or `offer_id`.
      properties:
        product_id: { type: string }
        gtin: { type: string }
        offer_id: { type: string }
        quantity: { type: integer, minimum: 1, default: 1, description: 'Compare only.' }

    CompareRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 50
          items: { $ref: '#/components/schemas/ItemRef' }
        retailers:
          type: array
          items: { type: string }
        location: { type: string }
        max_credits: { type: number, minimum: 0 }
        dry_run: { type: boolean, default: false }
        min_confidence: { type: number, minimum: 0, maximum: 1, default: 0.85 }
        match_kinds:
          type: array
          items: { type: string, enum: [identical, pack, equivalent] }
          default: [identical]
        price_type:
          type: string
          enum: [price, card_price]
          default: price
          description: Which price to total. `card_price` falls back to `price` when there is no card price.

    Comparison:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [items, retailers, totals]
          properties:
            items:
              type: array
              items:
                type: object
                required: [product, quantity, prices]
                properties:
                  product: { $ref: '#/components/schemas/ProductSummary' }
                  quantity: { type: integer }
                  prices:
                    type: object
                    description: Map of retailer ID to price (null when the retailer does not carry it).
                    additionalProperties: { type: [number, 'null'] }
            retailers:
              type: array
              items: { $ref: '#/components/schemas/RetailerRef' }
            totals:
              type: array
              description: Basket total per retailer, cheapest first.
              items:
                type: object
                required: [retailer, total, items_found, complete]
                properties:
                  retailer: { $ref: '#/components/schemas/RetailerRef' }
                  total: { type: number }
                  items_found: { type: integer }
                  complete: { type: boolean }
                  converted_total: { type: [number, 'null'] }
            currency: { type: string }
            not_found:
              type: array
              items: { $ref: '#/components/schemas/ItemRef' }
        meta: { $ref: '#/components/schemas/Meta' }

    BatchRequest:
      type: object
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 500
          items: { $ref: '#/components/schemas/ItemRef' }
        retailers:
          type: array
          items: { type: string }
        max_credits: { type: number, minimum: 0 }
        dry_run: { type: boolean, default: false }
        min_confidence: { type: number, minimum: 0, maximum: 1, default: 0.85 }
        match_kinds:
          type: array
          items: { type: string, enum: [identical, pack, equivalent] }
          default: [identical]

    BatchResult:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            type: object
            required: [input, found]
            properties:
              input: { $ref: '#/components/schemas/ItemRef' }
              found: { type: boolean }
              product_id: { type: [string, 'null'] }
              prices:
                type: array
                items: { $ref: '#/components/schemas/RetailerPrice' }
        meta: { $ref: '#/components/schemas/Meta' }

    Offer:
      type: object
      required: [id, retailer, title_original, url, current]
      properties:
        id: { type: string }
        retailer: { $ref: '#/components/schemas/RetailerRef' }
        product_id: { type: [string, 'null'] }
        store_id: { type: [string, 'null'] }
        title_original: { type: string }
        title_lang: { type: string, description: BCP 47 language of title_original. }
        url: { type: string, format: uri }
        retailer_sku: { type: [string, 'null'] }
        retailer_product_id: { type: [string, 'null'], description: 'Parent product in the store (groups variants and sizes). Availability is measured per product: available if any of its SKUs is in stock.' }
        seller: { $ref: '#/components/schemas/Seller' }
        gtin: { type: [string, 'null'] }
        brand: { type: [string, 'null'] }
        category: { oneOf: [{ $ref: '#/components/schemas/CategoryRef' }, { type: 'null' }] }
        match: { oneOf: [{ $ref: '#/components/schemas/Match' }, { type: 'null' }] }
        current: { $ref: '#/components/schemas/PriceQuote' }
        first_seen: { type: string, format: date-time }
        last_changed: { type: [string, 'null'], format: date-time }

    OfferList:
      type: object
      required: [data, pagination, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Offer' }
        pagination: { $ref: '#/components/schemas/Pagination' }
        meta: { $ref: '#/components/schemas/Meta' }

    Retailer:
      type: object
      required: [id, name, country, segments, website, coverage]
      properties:
        id: { type: string, example: cl-jumbo }
        name: { type: string, example: Jumbo }
        country: { type: string, example: CL }
        group: { type: [string, 'null'], example: Cencosud }
        segments: { type: array, items: { $ref: '#/components/schemas/Segment' } }
        website: { type: string, format: uri }
        timezone: { type: string, example: America/Santiago }
        location_pricing: { type: boolean, description: 'Whether prices vary by store or area.' }
        coverage:
          type: object
          required: [offers, matched_pct, frequency, history_since, last_observed]
          properties:
            offers: { type: integer }
            matched_pct: { type: number }
            frequency: { type: string, enum: [hourly, daily, weekly] }
            history_since: { type: [string, 'null'], format: date }
            last_observed: { type: [string, 'null'], format: date-time }

    Store:
      type: object
      required: [id, retailer_id, name, admin_area]
      properties:
        id: { type: string }
        retailer_id: { type: string }
        name: { type: string }
        address: { type: [string, 'null'] }
        admin_area:
          type: object
          required: [country, code, name]
          properties:
            country: { type: string }
            code: { type: string, description: 'For Chile, comuna CUT code.' }
            name: { type: string }
            region: { type: [string, 'null'] }
        lat: { type: [number, 'null'] }
        lng: { type: [number, 'null'] }

    Category:
      type: object
      required: [id, label, parent_id, level]
      properties:
        id: { type: string }
        label: { type: string }
        parent_id: { type: [string, 'null'] }
        level: { type: integer }
        products_count: { type: integer }
        mappings:
          type: object
          properties:
            google_product_taxonomy: { type: [integer, 'null'] }
            coicop_2018: { type: [string, 'null'], example: '01.1.4.1' }
            shopify_category_id: { type: [string, 'null'], description: Shopify Standard Product Taxonomy (MIT). GS1 GPC is not exposed without written permission from GS1. }

    Brand:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
        products_count: { type: integer }

    Coverage:
      type: object
      required: [data, meta]
      properties:
        data:
          type: object
          required: [totals, retailers, categories]
          properties:
            totals:
              type: object
              properties:
                countries: { type: integer }
                retailers: { type: integer }
                products: { type: integer }
                offers: { type: integer }
                observations: { type: integer }
                history_since: { type: [string, 'null'], format: date }
            retailers:
              type: array
              items:
                type: object
                properties:
                  retailer: { $ref: '#/components/schemas/RetailerRef' }
                  country: { type: string }
                  offers: { type: integer }
                  matched_pct: { type: number }
                  last_observed: { type: [string, 'null'], format: date-time }
                  history_since: { type: [string, 'null'], format: date }
            categories:
              type: array
              items:
                type: object
                properties:
                  category: { $ref: '#/components/schemas/CategoryRef' }
                  products: { type: integer }
                  retailers: { type: integer }
            planned:
              type: array
              description: Retailers or countries announced but not yet available.
              items: { type: string }
        meta: { $ref: '#/components/schemas/Meta' }

    Country:
      type: object
      required: [code, name, currency, languages, status]
      properties:
        code: { type: string }
        name: { type: string }
        currency: { type: string }
        currency_minor_unit: { type: integer }
        languages: { type: array, items: { type: string } }
        timezones: { type: array, items: { type: string } }
        fx_source: { type: [string, 'null'], description: Official source used for local currency to USD. }
        admin_area_scheme: { type: [string, 'null'], example: cl-cut-comuna }
        standard_vat_rate: { type: [number, 'null'], description: Informative only. }
        status: { type: string, enum: [live, beta, planned] }

    FxRate:
      type: object
      required: [base, quote, rate, date, source]
      properties:
        base: { type: string }
        quote: { type: string }
        rate: { type: number }
        date: { type: string, format: date, description: Date the rate applies to. }
        published_date: { type: string, format: date }
        kind: { type: string, enum: [official, mep], default: official }
        method: { type: string, enum: [direct, cross] }
        source: { type: string }

    Account:
      type: object
      required: [id, plan, credits]
      properties:
        id: { type: string }
        name: { type: string }
        plan: { type: string }
        environment: { type: string, enum: [live, test] }
        credits:
          type: object
          required: [balance]
          properties:
            balance: { type: number }
            included_monthly: { type: [number, 'null'] }
            resets_at: { type: [string, 'null'], format: date-time }
        limits:
          type: object
          properties:
            requests_per_second: { type: [integer, 'null'] }
            requests_per_minute: { type: [integer, 'null'] }
            requests_per_day: { type: [integer, 'null'] }
            history_days: { type: [integer, 'null'] }

    UsageRow:
      type: object
      required: [key, credits, requests]
      properties:
        key: { type: string, description: 'Group value (a date, an operation, a channel or a key ID).' }
        credits: { type: number }
        requests: { type: integer }

    ExportRequest:
      type: object
      required: [dataset, format]
      properties:
        dataset: { type: string, enum: [offers, products, observations] }
        format: { type: string, enum: [csv, jsonl, parquet] }
        filters:
          type: object
          properties:
            country: { type: string }
            retailer: { type: array, items: { type: string } }
            category: { type: string }
            from: { type: string, format: date }
            to: { type: string, format: date }
        currency: { type: string }
        lang: { type: string, enum: [es, en] }

    Job:
      type: object
      required: [id, status]
      properties:
        id: { type: string }
        status: { type: string, enum: [queued, running, succeeded, failed] }
        created_at: { type: string, format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }
        rows: { type: [integer, 'null'] }
        files:
          type: array
          items:
            type: object
            properties:
              url: { type: string, format: uri }
              bytes: { type: integer }
              expires_at: { type: string, format: date-time }
        error: { oneOf: [{ $ref: '#/components/schemas/Problem' }, { type: 'null' }] }

    DataRequestInput:
      type: object
      required: [kind, description]
      properties:
        kind: { type: string, enum: [retailer, country, category, frequency, delivery, other] }
        description: { type: string, maxLength: 2000 }
        retailer_url: { type: string, format: uri }
        country: { type: string }
        contact_email: { type: string, format: email }

    DataRequest:
      allOf:
        - $ref: '#/components/schemas/DataRequestInput'
        - type: object
          required: [id, status, created_at]
          properties:
            id: { type: string }
            status: { type: string, enum: [received, reviewing, quoted, accepted, declined, done] }
            created_at: { type: string, format: date-time }
