openapi: 3.0.3
info:
  title: PricesAPI Developer API
  description: >-
    Price intelligence infrastructure for developers. Product prices and history
    for your next app. The Product Search API is a Product Search &
    Merchant Offers API: search by product name or retailer URL and get matched
    candidates with available merchant offers inline. Global coverage across
    supported markets; availability varies by product and market.


    Authenticate with an API key sent as a bearer token
    (`Authorization: Bearer <key>`). Search is synchronous; cold searches can
    take several minutes, so clients should use a long read timeout. Pricing
    models, included allowances, and usage limits for all Beta features may
    change before general availability. Existing Product Search pricing and
    credit allowances remain unchanged unless separately announced.


    RATE LIMITS, per account per minute. Search depends on your plan: Personal
    6, Developer 20, Business 40, Enterprise 60. The other operations have their
    own buckets and do not consume the Search one: Product Catalog 30, Product
    Snapshot 60, Price History 60, Schedules management 60 shared across all
    eight Schedules operations, and Async Bulk Search management 30 shared
    across submit, status, results and cancel together. Exceeding one answers
    `429 RATE_LIMIT_EXCEEDED` with a `Retry-After` header.


    BATCH SNAPSHOT IS COUNTED IN ITEMS, NOT REQUESTS, and has its own bucket
    separate from single Product Snapshot. It allows 100 ITEMS per minute, and
    every requested item counts: one 25-item call spends 25, and one full
    100-item call spends the whole minute's budget, so a second full batch in the
    same minute is refused. Read the `60` above as applying to single-product
    Snapshot reads only. This is the limit most likely to be hit on a first
    integration, because a caller sizing a backfill at 60 requests x 100 items
    would be off by sixty-fold in the direction that fails.


    TWO LIMITS THAT ARE NOT PER-MINUTE, and that a caller sizing a job needs to
    know. Price History allows 2 requests IN FLIGHT per account: a third
    concurrent request is refused with `429 RATE_LIMIT_EXCEEDED` even well under
    60/min, so a page drawing several sparklines should walk them in sequence or
    at most two at a time. And one Price History window may scan at most 50,000
    STORED OFFER ROWS. Those are raw rows as collected, counted BEFORE the
    de-duplication that produces the published `offer_count`, so the two numbers
    are not comparable and a window whose `offer_count` looks comfortably under
    the cap can still exceed it. A window dense enough to do so is refused with
    `400 INVALID_DATE_RANGE`, so split it by date rather than widening it.
    Product Catalog's free `retained` summary uses different inclusion and
    counting rules. It does not predict the rows scanned for a History window,
    its charge, or whether that window will return offers.
  version: 1.0.0
  contact:
    name: Andrew at PricesAPI
    email: andrew@pricesapi.io
    url: https://pricesapi.io/docs
  termsOfService: https://pricesapi.io/terms
servers:
  - url: https://api.pricesapi.io
    description: Primary host
  - url: https://api.buywisely.com.au
    description: Alternate host (same API)
security:
  - bearerAuth: []
tags:
  - name: Products
    description: Product search and pricing.
  - name: Async Bulk Search
    description: Public Beta for asynchronous batches of Search Products requests, available to every valid API account. Beta pricing models, included allowances, and usage limits may change before general availability.
  - name: Schedules
    description: Caller-scoped recurring Search management. Schedules is in Beta. Limits and pricing are subject to change.
  - name: Price History
    description: Public Beta retained-data reads for sparse observed product prices, available to every valid API account. Availability and commercial terms may change during Beta.
  - name: Product Catalog
    description: Free catalogue lookup that resolves product text or a seller name to PricesAPI product IDs and the retained observation coverage held for each, available to every valid API account. It charges no credit and returns no prices. Beta limits may change before general availability.
paths:
  /api/v1/products/search:
    get:
      operationId: searchProducts
      tags:
        - Products
      summary: Search products with offers inline
      description: >-
        The synchronous Product Search operation. Pass a product URL OR
        brand+model text as `q`. One call runs product discovery and offer
        lookup inline and returns up to `limit` ranked candidates, each carrying
        its seller offers.


        SYNCHRONOUS: cold searches can take several minutes. Set a long read
        timeout.


        No matched products returned is a `200` with `data.products = []`, not
        a 404. A `200` can also contain products whose `offers` arrays are empty.
        `meta.degraded` is optional diagnostic metadata; inspect the returned
        products and offers, not that flag, to determine whether offers were
        returned. Neither response establishes that a product does not exist
        or is out of stock. A later retry may return offers, but is not guaranteed to.


        Billing: 10 credits only when a 200 Search Products response
        includes at least one merchant offer. Cache hits follow the same billing
        rule. Results with no products or no merchant offers, and 4xx/5xx responses,
        use no credit. Known-product Snapshots with zero offers still bill under
        their separate retained-read rules.
      parameters:
        - in: query
          name: q
          required: true
          description: >-
            Product URL OR brand+model text (e.g. `iphone 15 pro 256gb` or a
            retailer product URL). Missing -> 400 MISSING_QUERY.
          schema:
            type: string
          example: iphone 15 pro 256gb
        - in: query
          name: market
          required: true
          description: >-
            Required lowercase two-letter market code, such as `au`, `us`,
            `gb`, `nz` or `ca`. Use `gb`, not `uk`. There is no default.
            Missing or unsupported codes return 400 INVALID_MARKET with the
            supported list. `country` is a permanent alias; `market` wins if
            both are supplied.
          schema:
            type: string
            example: au
        - in: query
          name: limit
          required: false
          description: >-
            Maximum candidate products to return. Default 3. The maximum
            depends on your plan: Personal 3, Developer 10, Business and
            Enterprise 60. Requests above the plan maximum are served at
            that maximum, with `meta.limit_capped: true` and `meta.limit_max`
            reporting the applied ceiling.

            Zero and invalid integers (such as `2.9`, `3abc`, `+3`, `03` or an
            empty value) return 400 INVALID_LIMIT naming the range. This is
            the one limit on the platform that shortens requests above their
            maximum; `offers_limit` rejects out-of-range values.
          schema:
            type: integer
            default: 3
            minimum: 1
            maximum: 60
            example: 3
        - in: query
          name: offers_limit
          required: false
          description: >-
            Maximum offers per candidate in `offers[]`. Default 15. A smaller
            limit keeps the first offers in collected order, not a guaranteed
            price order. `offerCount` is the returned array length, not the
            total number of sellers available. Reaching the limit does not
            tell you whether more offers were collected.

            Anything outside 1 to 60 returns 400 INVALID_OFFERS_LIMIT. Invalid
            integers such as `2.9`, `3abc`, `+3`, `03` or an empty value return
            the same error. The error names the accepted range; values are
            rejected rather than clamped.
          schema:
            type: integer
            default: 15
            minimum: 1
            maximum: 60
            example: 3
        - in: query
          name: max_age
          required: false
          description: >-
            Maximum acceptable age in seconds for scheduled stored data. When
            Schedules serving or fallback is enabled, this must contain only
            non-negative decimal digits; invalid values return 400
            INVALID_MAX_AGE. Supplying it is an explicit freshness requirement,
            so stale scheduled fallback is not used. When those features are
            disabled, the parameter is ignored.
          schema:
            type: integer
            minimum: 0
        - in: query
          name: no_cache
          required: false
          description: >-
            Values `1`, `true`, or `yes` (case-insensitive) bypass Redis,
            Postgres, and scheduled-store reads, force a live scrape, and still
            refresh caches. This is an explicit freshness requirement, so stale
            scheduled fallback is not used. Other values behave as false.
          schema:
            type: string
            example: 'true'
      responses:
        '200':
          description: >-
            Search succeeded. `data.products` may be empty (Google had nothing).
            `meta.degraded = true` indicates candidates were returned with empty
            offers because the offers stage failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchResponse'
        '400':
          description: >-
            Invalid request. `error.code` is one of MISSING_QUERY,
            INVALID_MARKET (`market` omitted, or a code we do not support — the
            message says which, and `supported` carries the list), INVALID_LIMIT,
            INVALID_OFFERS_LIMIT, INVALID_MAX_AGE (when Schedules serving or
            fallback is enabled), or INVALID_PARAMETER.


            INVALID_PARAMETER is returned for ANY query parameter this
            operation does not accept, instead of ignoring it. The message
            names the unrecognised key, says what replaced it when it is a
            retired name, and lists every parameter the operation accepts, so
            the request can be fixed from the error alone. Accepted here: `q`,
            `market`, `limit`, `offers_limit`, `max_age`, `no_cache`, plus
            `api_key` on every operation as the deprecated query-string auth
            path.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: >-
            Authentication failed. `error.code` is MISSING_API_KEY,
            INVALID_API_KEY_FORMAT, or INVALID_API_KEY.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            The subscription is cancelled or its API credits are exhausted.
            `error.code` is SUBSCRIPTION_CANCELLED, CREDITS_EXCEEDED, or
            MONTHLY_LIMIT_EXCEEDED. The two quota spellings come from the
            pre-auth check and the atomic usage check respectively.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: >-
            Rate limit exceeded for this account (per-minute limit depends on
            plan: Personal 6, Developer 20, Business 40, Enterprise 60).
            Repeated over-quota Search requests can also return this status
            with credit-quota details. If credit is exhausted, pause until it
            is available; Retry-After alone is not a reason to resume.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            Server-side fault. `error.code` is DB_ERROR, SEARCH_FAILED, or
            INTERNAL_ERROR. Not the client's to retry. A fault while checking
            the API key is INTERNAL_ERROR and says so — it used to be reported
            as VALIDATION_ERROR *"Failed to validate API key"*, which pointed
            the customer at a key that was never the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: >-
            Scraper is transiently unavailable, busy, or its circuit is open.
            `error.code` is SCRAPER_UNAVAILABLE, SCRAPER_TIMEOUT, SCRAPER_BUSY,
            SCRAPER_CIRCUIT_OPEN, or SERVICE_UNAVAILABLE. Back off and retry
            after the `Retry-After` header (seconds).
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                example: 30
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/products:
    get:
      operationId: getProductSnapshot
      tags:
        - Products
      summary: Read a known product by an explicit typed ID
      description: >-
        Public Beta for every valid API account. PricesAPI and Google Shopping
        IDs use this same route and return the same canonical Product shape for
        one explicit market. This retained-data read does not run Search and does
        not call a scraper.


        Billing: 1 credit per product returned, so 1 credit per 200 response. It
        is charged after the request validates and refunded on every outcome that
        is not a 200 — a rejected parameter is never charged at all, and a
        PRODUCT_NOT_FOUND or an unavailable projection gives the credit back. A
        Search costs 10 credits, so a Snapshot is a tenth of one. Pricing models,
        included allowances, and usage limits for all Beta features may change
        before general availability.


        THE RESPONSE SHAPE IS NOT SEARCH'S. A 200 here returns `product`,
        `observation`, `offers` and `offers_page` at the TOP LEVEL — there is no
        `success` and no `data` wrapper. `GET /api/v1/products/search` does wrap
        its payload in `success` + `data`, so a reader written for Search asks
        for `body.data.products` here, gets undefined, and reports an empty result
        rather than an error. Branch on `error` instead: every operation in this
        contract returns `{ success: false, error: { code, message } }` on a
        failure, while `success` appears only on the wrapped successes.


        The field names differ too. Search's `pid`, `image` and `offerCount` are
        `product.id` (a string, not an integer), `product.image_url` and
        `offers_page.returned` here; its per-offer `product_title` and
        `stock_status` are `title` and `availability` (with the seller's own
        wording in `availability_raw`). Snapshot offers also carry `seller_id`,
        `seller_domain` and `observed_at`, which Search offers do not.


        Limit BOUNDS belong to the operation, not to the parameter name:
        `offers_limit` is 0 to 60 here, 1 to 60 on
        `GET /api/v1/products/search`, and 1 to 60 per item on
        `POST /api/v1/search/jobs`. Out-of-range BEHAVIOUR is the same
        everywhere — a 400 that names the range. Nothing is ever quietly
        reduced, so a short list of offers always means a short list of offers.
        Read the parameter list on the operation you are calling.
      parameters:
        - $ref: '#/components/parameters/ProductSnapshotId'
        - $ref: '#/components/parameters/ProductSnapshotIdType'
        - $ref: '#/components/parameters/ProductSnapshotMarket'
        - $ref: '#/components/parameters/ProductSnapshotOffersLimit'
        - $ref: '#/components/parameters/ProductSnapshotSellerDomains'
      responses:
        '200':
          description: Canonical product plus bounded latest retained merchant offers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotResponse'
        '400':
          description: >-
            Invalid identifier, identifier authority, market, offer limit, or
            seller domain.


            Also INVALID_PARAMETER for ANY query parameter this operation does
            not accept. It accepts `market`, `id`, `id_type`, `offers_limit` and
            `seller_domains` (plus `api_key` everywhere). An unrecognised key is
            REFUSED, never ignored, and the message names what replaced it —
            `retailer` and `retailers` became `merchant_domains` and then
            `seller_domains`, both on 2026-09-17, and a dropped filter would
            return an unfiltered offer list looking like a filtered one.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '401':
          description: >-
            MISSING_API_KEY, INVALID_API_KEY_FORMAT, or INVALID_API_KEY. The
            authentication boundary answers before this operation runs, so the
            response is the same one every authenticated operation returns. The
            first two carry a `details` string explaining how to present the key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductSnapshotErrorResponse' }
        '403':
          description: >-
            INSUFFICIENT_CREDITS — the account does not hold the 1 credit this
            read costs. The message and `details` name what the request would
            have cost (`credits_required`), what is left (`credits_remaining`),
            and when the included credits renew. Retrying does not help.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '404':
          description: >-
            The exact product mapping was not found in this market. The credit
            reserved for the read is refunded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '405':
          description: Only GET is supported. No Product read, meter or charge occurs.
          headers:
            Allow:
              schema:
                type: string
                enum: [GET]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '429':
          description: The account-scoped Product Snapshot rate limit was exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '503':
          description: >-
            Product Snapshot retained-data projection is temporarily unavailable.
            The credit reserved for the read is refunded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
  /api/v1/products/batch:
    post:
      operationId: batchGetProductSnapshots
      tags:
        - Products
      summary: Read up to 100 known products in one ordered request
      description: >-
        Public Beta for every valid API account. Read 1 to 100 exact Product
        identities using the same typed ID and explicit-market contract as the
        single Product route. Results preserve input order; identical inputs keep
        every original position while sharing one physical read; PRODUCT_NOT_FOUND
        is a per-item result. This retained-data request does not run Search and
        does not call a scraper.


        Billing: 1 credit per product RETURNED, never one per call — the same
        price per product as the single Product route, because it is the same
        retained read. A per-item PRODUCT_NOT_FOUND costs nothing, and a request
        that fails as a whole costs nothing. Identical inputs are charged once per
        returned POSITION: send the same ID twice and you receive it twice.
        Because the number returned is only known after the read, the whole
        requested count is reserved up front and the misses are given straight
        back, so an account must be able to cover every item it asked for — a
        25-item call needs 25 credits available even if only 3 of those products
        exist. Pricing models, included allowances, and usage limits for all Beta
        features may change before general availability.


        Each requested item counts against the account's separate batch-read
        rate limit; the request does not count as only one read.


        A 200 here returns `results` and `summary` at the TOP LEVEL — there is no
        `success` and no `data` wrapper. Each found entry in `results` spreads the
        single-product body beside its own `input_index` and `status`. A failure
        does carry `success: false` with `error`, so branch on `error` rather than
        on `success`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProductSnapshotBatchRequest'
      responses:
        '200':
          description: Ordered Product snapshots and explicit per-item misses.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotBatchResponse'
        '400':
          description: Invalid JSON, batch shape, item, identifier, market, offer limit, or seller domain.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '401':
          description: >-
            MISSING_API_KEY, INVALID_API_KEY_FORMAT, or INVALID_API_KEY. The
            authentication boundary answers before this operation runs, so the
            response is the same one every authenticated operation returns. The
            first two carry a `details` string explaining how to present the key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductSnapshotErrorResponse' }
        '403':
          description: >-
            INSUFFICIENT_CREDITS — the account cannot cover every item this batch
            requested. The reservation is the whole requested count, because the
            number of products returned is not known until after the read, so
            `credits_required` names the items asked for rather than the ones that
            would have been found. `details` also carries what is left
            (`credits_remaining`) and when the included credits renew. Retrying
            does not help; asking for fewer items does.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '405':
          description: Only POST is supported. No Product read or meter occurs.
          headers:
            Allow:
              schema:
                type: string
                enum: [POST]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '413':
          description: The JSON body exceeded the 100 KB request ceiling.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '429':
          description: The account-scoped Product Snapshot rate limit was exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '503':
          description: >-
            Product Snapshot retained-data projection is temporarily unavailable.
            Every credit reserved for the batch is refunded, including any already
            given back for per-item misses — the whole request costs nothing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
  /api/v1/product-clusters:
    get:
      operationId: listProductClusterMembers
      tags:
        - Products
      summary: Page the known products in a product cluster
      description: >-
        Public Beta for every valid API account. Returns deterministic known
        membership for one explicit Google product-cluster ID and market; it is
        not one product, Search ranking, or guaranteed exhaustive variants. A
        product cluster is Google's variant grouping (gpcid), the same value
        in every country, addressed with `id_type=cluster`; a single
        product is Google's catalog ID (gid), one variant in one market,
        addressed with `id_type=catalog` on `GET /api/v1/products`. The two
        were one `google_shopping` token, and picking the wrong namespace
        returned a 404 saying the product was not in this market — about a
        product we hold.
        Billing: 1 credit per PAGE returned, never one per member — one credit
        whether the page carries one member or a hundred, because the request
        sets `limit`. It is the same price as one Product Snapshot, because it
        is the same retained read. It is charged after the request validates and
        refunded on every outcome that is not a 200, and on a 200 carrying no
        members: a rejected parameter or cursor is never charged at all, and a
        PRODUCT_CLUSTER_NOT_FOUND or an unavailable projection gives the credit
        back. A Search costs 10 credits, so a page is a tenth of one. Pricing
        models, included allowances, and usage limits for all Beta features may
        change before general availability.


        A 200 here returns `cluster`, `products` and `page` at the TOP LEVEL —
        there is no `success` and no `data` wrapper. A failure does carry
        `success: false` with `error`, so branch on `error` rather than on
        `success`.
      parameters:
        - $ref: '#/components/parameters/ProductClusterId'
        - $ref: '#/components/parameters/ProductClusterIdType'
        - $ref: '#/components/parameters/ProductSnapshotMarket'
        - $ref: '#/components/parameters/ProductClusterLimit'
        - $ref: '#/components/parameters/ProductClusterCursor'
      responses:
        '200':
          description: A bounded deterministic page of known products.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductClusterPageResponse'
        '400':
          description: >-
            Invalid product-cluster ID, authority, market, limit, or cursor.


            Also INVALID_PARAMETER for ANY query parameter this operation does
            not accept. It accepts `market`, `id`, `id_type`, `limit` and
            `cursor` (plus `api_key` everywhere); note `offers_limit` is a
            single-product parameter and is refused here.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '401':
          description: >-
            MISSING_API_KEY, INVALID_API_KEY_FORMAT, or INVALID_API_KEY. The
            authentication boundary answers before this operation runs, so the
            response is the same one every authenticated operation returns. The
            first two carry a `details` string explaining how to present the key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductSnapshotErrorResponse' }
        '403':
          description: >-
            INSUFFICIENT_CREDITS — the account does not hold the 1 credit this
            page costs. The message and `details` name what the request would
            have cost (`credits_required`), what is left (`credits_remaining`),
            and when the included credits renew. Nothing is charged and no
            cluster read occurs. Retrying does not help.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '404':
          description: >-
            The product cluster was not found in this market. The credit
            reserved for the page is refunded in full.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '405':
          description: Only GET is supported. No cluster read, meter or charge occurs.
          headers:
            Allow:
              schema:
                type: string
                enum: [GET]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '429':
          description: The account-scoped Product Snapshot rate limit was exceeded.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
        '503':
          description: Product Snapshot retained-data projection is temporarily unavailable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProductSnapshotErrorResponse'
  /api/v1/schedules:
    get:
      operationId: listSchedules
      tags: [Schedules]
      summary: List recurring searches and cadence health
      description: >-
        List only this API account's Schedules, including latest catalogue-discovery
        activity and caller-scoped cadence health. Optional account-wide analytics
        can be requested for 7, 30, or 90 days; if that supplemental read is
        unavailable, the schedule rows still return. This is a credit-free
        management operation and never runs Search. Schedules is in Beta. Limits
        and pricing are subject to change.
      parameters:
        - name: market
          in: query
          required: false
          description: >-
            Supported market code. Matching is case-insensitive. Optional here
            because it filters the caller's own rows; it has no default, and
            omitting it returns every market.
          schema: { type: string }
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [starting, active, recovering, paused]
        - name: origin
          in: query
          required: false
          schema:
            type: string
            enum: [auto, manual, bulk]
        - name: q
          in: query
          required: false
          description: Normalized substring filter over caller-owned terms.
          schema: { type: string, minLength: 1, maxLength: 512 }
        - name: frequency_minutes
          in: query
          required: false
          description: >-
            Cadence filter in minutes, spelled exactly as the read side
            returns it.
          schema:
            type: integer
            enum: [60, 360, 720, 1440, 10080]
        - name: activity_days
          in: query
          required: false
          schema:
            type: integer
            enum: [7, 30, 90]
            default: 7
        - name: cursor
          in: query
          required: false
          description: Exact opaque continuation value issued by this operation.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: One caller-scoped schedule page and optional account-wide analytics.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleListResponse' }
        '400':
          description: >-
            Invalid filter, analytics window, cursor, market, or limit. A query
            parameter this operation does not accept is refused with
            INVALID_PARAMETER naming the key and listing what is accepted:
            `activity_days`, `cursor`, `frequency_minutes`, `limit`, `market`,
            `origin`, `q`, `status`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '404':
          description: Schedules is unavailable in this deployment or the route is unknown.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
    post:
      operationId: createSchedules
      tags: [Schedules]
      summary: Add recurring searches
      description: >-
        Add one or more caller-owned recurring searches. Each term carries its
        own explicit `market` and its cadence as `frequency_minutes`. Duplicate normalized
        term-and-market pairs report exists rather than creating a second schedule;
        each input returns its own outcome. Send each term as an object with `term`
        and `market`. A legacy bare-string entry creates no schedule and returns
        `status: invalid` with `reason: missing_market` for that item. Check each
        result and the summary: HTTP 200 does not mean every item was added.
        The management operation is credit-free,
        and background scheduled refreshes use no credit. A later
        customer-initiated non-empty Search follows normal billing. Schedules is in
        Beta. Limits and pricing are subject to change.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScheduleCreateRequest' }
            example:
              terms:
                - term: Sony WH-1000XM5
                  market: au
      responses:
        '200':
          description: Per-input add, existing, cap, invalid, or retryable outcomes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleCreateResponse' }
        '400':
          description: Invalid body or too many terms.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled or a reserved allocation is not permitted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '404':
          description: Schedules is unavailable in this deployment or the route is unknown.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '413':
          description: The authenticated Schedules request body exceeded its configured limit.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedule changes are paused or Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
  /api/v1/schedules/history:
    get:
      operationId: listScheduleHistory
      tags: [Schedules]
      summary: List recent Search demand for adding to Schedules
      description: >-
        Page this API account's normalized Search history and caller-scoped schedule
        state. Optional demand analytics use the same 7, 30, or 90 day window and may
        be omitted without hiding history rows. This is a credit-free management read
        and never reruns Search. Schedules is in Beta. Limits and pricing are subject
        to change.
      parameters:
        - name: days
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 90, default: 90 }
        - name: market
          in: query
          required: false
          description: >-
            Supported market code. Matching is case-insensitive. Optional here
            because it filters the caller's own rows; it has no default, and
            omitting it returns every market.
          schema: { type: string }
        - name: cursor
          in: query
          required: false
          description: Exact opaque continuation value issued by this operation.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        '200':
          description: One caller-scoped Search-history page with optional demand analytics.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleHistoryResponse' }
        '400':
          description: >-
            Invalid days, market, cursor, or limit. A query parameter this
            operation does not accept is refused with INVALID_PARAMETER naming
            the key and listing what is accepted: `cursor`, `days`, `limit`,
            `market`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '404':
          description: Schedules is unavailable in this deployment or the route is unknown.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
  /api/v1/schedules/settings:
    get:
      operationId: getScheduleSettings
      tags: [Schedules]
      summary: Read account Schedule settings
      description: >-
        Read this API account's automatic-enrolment and default-cadence settings.
        This is a credit-free management operation and never runs Search. Schedules
        is in Beta. Limits and pricing are subject to change.
      responses:
        '200':
          description: Caller-owned Schedule settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleSettingsResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '404':
          description: Schedules is unavailable in this deployment or the route is unknown.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
    patch:
      operationId: updateScheduleSettings
      tags: [Schedules]
      summary: Update account Schedule settings
      description: >-
        Update automatic enrolment, its daily-or-weekly default, or the inactivity
        eviction window for this API account. This is a credit-free management
        operation; background refreshes use no credit. Schedules is in Beta.
        Limits and pricing are subject to change.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScheduleSettingsUpdateRequest' }
      responses:
        '200':
          description: Updated caller-owned Schedule settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleSettingsResponse' }
        '400':
          description: Invalid body, cadence, or eviction window.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '404':
          description: Schedules is unavailable in this deployment or the route is unknown.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedule changes are paused or Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
  /api/v1/schedules/{id}:
    get:
      operationId: getSchedule
      tags: [Schedules]
      summary: Read one caller-owned schedule
      description: >-
        Read one Schedule with latest discovery activity, cadence health, and the
        caller's available cadence choices. Another account's identifier returns the
        same 404 as an unknown identifier. This is a credit-free management operation
        and never runs Search. Schedules is in Beta. Limits and pricing are subject to
        change.
      parameters:
        - $ref: '#/components/parameters/ScheduleId'
      responses:
        '200':
          description: One caller-owned Schedule.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleDetailResponse' }
        '400':
          description: id is not a positive integer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '404':
          description: No such Schedule belongs to this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
    patch:
      operationId: updateSchedule
      tags: [Schedules]
      summary: Pause, resume, or change one schedule
      description: >-
        Change this caller's cadence and/or paused state. Resuming or choosing a
        faster cadence rechecks the account's schedule-slot cap. This credit-free
        management operation does not consume a credit. Schedules is in Beta.
        Limits and pricing are subject to change.
      parameters:
        - $ref: '#/components/parameters/ScheduleId'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ScheduleUpdateRequest' }
      responses:
        '200':
          description: Updated caller-owned Schedule.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleDetailResponse' }
        '400':
          description: Invalid id, body, cadence, or unavailable cadence.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '404':
          description: No such Schedule belongs to this account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '409':
          description: The requested change would exceed the account schedule-slot cap.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedule changes are paused or Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
    delete:
      operationId: deleteSchedule
      tags: [Schedules]
      summary: Remove one caller-owned schedule
      description: >-
        Idempotently remove this API account's subscription. An unknown or
        other-account identifier returns deleted false without revealing whether it
        exists. This is a credit-free management operation and consumes no Search
        credit. Schedules is in Beta. Limits and pricing are subject to change.
      parameters:
        - $ref: '#/components/parameters/ScheduleId'
      responses:
        '200':
          description: Removal result; deleted is false when nothing belonged to the caller.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleRemoveResponse' }
        '400':
          description: id is not a positive integer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '429':
          description: The separate Schedules management rate limit was exceeded.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
        '503':
          description: Schedule changes are paused or Schedules is temporarily unavailable.
          headers:
            Retry-After: { $ref: '#/components/headers/SchedulesRetryAfter' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ScheduleErrorResponse' }
  /api/v1/search/jobs:
    post:
      operationId: submitSearchJob
      tags:
        - Async Bulk Search
      summary: Submit an asynchronous Search job
      description: >-
        Submit strict JSON Search requests as one account-scoped Beta job. The
        public Beta is available to every valid API account. The configured Beta
        ceiling is currently 100 items per job. The complete JSON body must not
        exceed 100 KB. Submission
        atomically reserves 10 credits per item or creates no job. A
        successful result with at least one merchant offer consumes the reservation;
        items with no products or no merchant offers, failed items, and
        cancelled-before-start items are refunded. This management request
        uses no additional credit.
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          description: >-
            A caller-generated value from 1 to 255 characters. Reusing the key
            with the same normalized, ordered request returns the original job;
            reusing it with a different request returns 409.
          schema:
            type: string
            minLength: 1
            maxLength: 255
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BulkSearchSubmission'
      responses:
        '200':
          description: Job created, or the matching idempotent submission replayed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkSearchSubmitResponse'
        '400':
          description: >-
            Invalid idempotency key, body, item, market, or JSON. Codes include
            MISSING_IDEMPOTENCY_KEY, INVALID_IDEMPOTENCY_KEY, INVALID_BODY,
            INVALID_ITEM_COUNT, INVALID_ITEM, INVALID_MARKET, and
            INVALID_JSON.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: >-
            The subscription is cancelled or the account cannot reserve 10
            credits for every item. Code is SUBSCRIPTION_CANCELLED or
            INSUFFICIENT_CREDITS. No partial job is created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: >-
            Async Bulk Search is unavailable in this deployment or the route is
            not found. The API retains its ordinary unknown-route posture
            (NOT_FOUND).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The Idempotency-Key was already used with different normalized input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: The JSON request body exceeds the 100 KB Bulk Search ceiling.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Too many Bulk Search management requests for this API key.
          headers:
            Retry-After:
              $ref: '#/components/headers/BulkSearchRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            An internal server fault, including a fault while checking the API
            key. `error.code` is INTERNAL_ERROR; the key is not the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Bulk Search or API-key validation is temporarily unavailable.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                example: 5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/search/jobs/{id}:
    get:
      operationId: getSearchJob
      tags:
        - Async Bulk Search
      summary: Read Search job status
      description: >-
        Return caller-scoped counts of items by processing state and credit state.
        The reserved, consumed and refunded fields count items, not credit amounts.
        The job is complete when
        pending plus running equals zero. This management request uses no
        additional credit.
      parameters:
        - $ref: '#/components/parameters/SearchJobId'
      responses:
        '200':
          description: Current job counters.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkSearchJobResponse'
        '400':
          description: id is not a UUID (INVALID_JOB_ID).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No such Search job belongs to this account (JOB_NOT_FOUND).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Too many Bulk Search management requests for this API key.
          headers:
            Retry-After:
              $ref: '#/components/headers/BulkSearchRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            An internal server fault, including a fault while checking the API
            key. `error.code` is INTERNAL_ERROR; the key is not the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Bulk Search or API-key validation is temporarily unavailable.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                example: 5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    delete:
      operationId: cancelSearchJob
      tags:
        - Async Bulk Search
      summary: Request Search job cancellation
      description: >-
        Idempotently cancel and refund pending items. Running items may still
        complete, and terminal items remain unchanged. This operation does not
        delete the job and uses no additional credit.
      parameters:
        - $ref: '#/components/parameters/SearchJobId'
      responses:
        '200':
          description: Updated job counters after requesting cancellation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkSearchJobResponse'
        '400':
          description: id is not a UUID (INVALID_JOB_ID).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No such Search job belongs to this account (JOB_NOT_FOUND).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Too many Bulk Search management requests for this API key.
          headers:
            Retry-After:
              $ref: '#/components/headers/BulkSearchRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            An internal server fault, including a fault while checking the API
            key. `error.code` is INTERNAL_ERROR; the key is not the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Bulk Search or API-key validation is temporarily unavailable.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                example: 5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/search/jobs/{id}/results:
    get:
      operationId: listSearchJobResults
      tags:
        - Async Bulk Search
      summary: Page through Search job items
      description: >-
        Return every item state in original input order. Results can be read
        while work is in progress. Pass next_cursor back unchanged; null marks
        the final page. Ordering and cursor boundaries are stable, but item
        states are live rather than a snapshot. After pending + running reaches
        zero, make a fresh pass from the first page to collect final outcomes.
        expires_at is the execution cutoff: unfinished pending
        items are cancelled and refunded, while already-running work may finish.
        Status and results remain readable after the cutoff in the current Beta,
        but are not durable storage; collect them promptly. This management
        request uses no additional credit.
      parameters:
        - $ref: '#/components/parameters/SearchJobId'
        - in: query
          name: limit
          required: false
          description: >-
            Number of items per page, an integer from 1 to 200. Defaults to 50
            when omitted. A value outside that range returns 400 INVALID_LIMIT —
            this operation refuses the request, it never reduces the value to
            200.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - in: query
          name: cursor
          required: false
          description: Exact opaque next_cursor value issued for this job.
          schema:
            type: string
            maxLength: 200
      responses:
        '200':
          description: One input-ordered page with stable cursor boundaries and live item states, not a snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkSearchResultsResponse'
        '400':
          description: INVALID_JOB_ID, INVALID_LIMIT, or INVALID_CURSOR.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: API-key authentication failed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: The subscription is cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No such Search job belongs to this account (JOB_NOT_FOUND).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Too many Bulk Search management requests for this API key.
          headers:
            Retry-After:
              $ref: '#/components/headers/BulkSearchRetryAfter'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: >-
            An internal server fault, including a fault while checking the API
            key. `error.code` is INTERNAL_ERROR; the key is not the problem.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Bulk Search or API-key validation is temporarily unavailable.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: integer
                example: 5
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /api/v1/products/history:
    get:
      operationId: getProductHistory
      tags:
        - Price History
      summary: Read individual observed offers for one exact product
      description: >-
        Public Beta for every valid API account. Resolves either exact identifier to one canonical
        PricesAPI Product and returns the individual observed offers in an
        inclusive UTC window — each offer carrying which seller published it, the
        price, the seller product page it was read from, and the
        instant PricesAPI recorded it. Only
        observations that exist are returned; missing periods are omitted, never
        zero or fill-forward. The operation does not invoke Search or a scraper. Coverage
        is global across supported markets; Australia currently has the deepest
        retained-history cohort and other markets continue to accumulate data.


        CREDITS ARE CHARGED PER DAY OF OFFERS RETURNED, not per call: one window
        costs one credit for each day inside it that carried at least one offer,
        which is exactly the `window.days_with_offers` the response publishes. A
        365-day window over a product observed on 18 days costs 18 credits. A
        window that returned no offers costs nothing, and so does any failed
        request. PAGING A WINDOW COSTS NOTHING MORE — the window is charged once,
        on the first page. Following its `next_cursor` adds no charge. The account
        must initially have one available credit per requested day; credits for
        days without offers are returned after the read. Insufficient balance
        returns `403 INSUFFICIENT_CREDITS`. The free `findCatalogProducts` lookup
        reports `retained.days_with_offers` for new-condition Catalog records
        with a recorded price. History uses different inclusion and counting
        rules, so Catalog totals are neither a quote for the selected date
        window nor a guarantee that History will return offers.


        A 200 here returns `product`, `window`, `offers` and `page` at the TOP
        LEVEL — there is no `success` and no `data` wrapper. A failure does carry
        `success: false` with `error`, so branch on `error` rather than on
        `success`.


        BREAKING CHANGE: `group_by` has been removed and now returns `400
        INVALID_PARAMETER`. The day and month rollups this operation used to
        return are derivable from `offers[]`; aggregate them in your application.
        `summary` is gone and `range` is now `window`.


        BREAKING CHANGE: every counter is now named for what it counts.
        `requested_days` is `days_requested`, `observed_days` is
        `days_with_offers` (this is the billing key), `observation_count` is
        `times_with_offers`, `first_observed_at` / `last_observed_at` are
        `first_recorded_at` / `last_recorded_at`, and each offer carries
        `recorded_at` where it carried `observed_at`. `offer_count` is
        unchanged. `missing_days` is REMOVED, not renamed: it equals
        `days_requested - days_with_offers`, and both of those are published
        beside it.


        BREAKING CHANGE: each offer says `seller` and `seller_domain` where it
        said `merchant` and `merchant_domain` — the same word as the `seller_id`
        already beside them, and the same word Search already prints. Each offer
        also publishes four fields it used to drop: `seller_product_url`,
        `product_title`, `stock_status` and `delivery_info`. Every one of the
        four is nullable, so read a null as an absence and never as a zero or a
        no.


        BREAKING CHANGE: `condition` is REMOVED from every offer. It was a
        three-value enum that went null for anything outside it, so it could not
        be filtered on. Every condition is still counted and still returned;
        only the field is gone. See the migration guide in the API docs.
      parameters:
        - name: id
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 64 }
        - name: id_type
          in: query
          required: true
          description: >-
            The authority of `id`, never inferred from its value.
            `catalog` is Google's catalog ID (gid): one product variant in one
            market, not a universal product id.
          schema: { type: string, enum: [pricesapi, catalog] }
        - name: market
          in: query
          required: true
          schema: { type: string, pattern: '^[a-z]{2}$' }
        - name: from
          in: query
          required: true
          schema: { type: string, format: date }
        - name: to
          in: query
          required: true
          description: >-
            Inclusive canonical UTC date. Must not be later than the current UTC
            date, and the window must span at most 1500 days.
          schema: { type: string, format: date }
        - name: limit
          in: query
          required: false
          description: >-
            Offers per page, an integer from 1 to 1000. Defaults to 250 when
            omitted. A value outside that range returns 400 INVALID_LIMIT naming
            it — this operation refuses the request, it never reduces the value
            to 1000.
          schema: { type: integer, minimum: 1, maximum: 1000, default: 250 }
        - name: cursor
          in: query
          required: false
          description: >-
            Opaque `page.next_cursor` from the previous page. Copy it unchanged
            and repeat the original REST query's
            `id`, `id_type`, `market`, `from`, `to` and `limit` exactly.
            If `limit` was omitted, keep it omitted even when the response's
            `page.limit` is 250. A cursor stops
            continuing a walk after 24 hours, if the ID resolves to a different
            product, or if data-visibility rules change, returning `409 CURSOR_EXPIRED`.
            Older pagination formats may expire before their normal 24-hour lifetime.
            Stop on expiry; do not restart automatically. Starting again is optional
            and is a new billable first-page read with a new reservation. Keep partial
            results visibly incomplete unless you explicitly start a new walk.
            If you restart, omit the cursor and discard the previous walk's offers;
            do not mix the two walks.
            Late-arriving or removed records do not by themselves expire it.
            Coverage is pinned on page 1; the final row count can differ from
            `window.offer_count`. A null next_cursor ends the walk, not an immutable export.
          schema: { type: string, minLength: 1, maxLength: 3072 }
      responses:
        '200':
          description: Observed offers, including a valid empty known-product window
          headers:
            Cache-Control:
              schema: { type: string, enum: ['private, no-store'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryResponse' }
        '400':
          description: >-
            Invalid product, id_type, market, date range, limit or cursor, or
            INVALID_PARAMETER for an unsupported parameter — including the
            removed `group_by`
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryError' }
        '401':
          description: Missing, malformed, or unknown API key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryAuthError' }
        '403':
          description: >-
            INSUFFICIENT_CREDITS — the account cannot cover this window. The
            charge is only known after the read, so the reservation takes the
            WORST CASE, `days_requested`, and the unused days are refunded the
            moment the window is served. `error.details.credits_required`
            therefore names the requested span, which is the most a window this
            wide could cost, not the smaller amount a sparse window ends up
            charging. Ask for a narrower window, or add credits.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryError' }
        '404':
          description: PRODUCT_NOT_FOUND in the requested market, or the Price History resource is currently unavailable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryError' }
        '405':
          description: METHOD_NOT_ALLOWED
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryError' }
        '409':
          description: >-
            CURSOR_EXPIRED. Older pagination formats may expire before their normal
            24-hour lifetime. Stop; do not restart automatically. Starting again is
            optional and is a new billable first-page read with a new reservation.
            Keep partial results visibly incomplete unless you explicitly start a
            new walk. If you restart, omit the cursor and discard the previous
            walk's offers; do not mix the two walks.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryError' }
        '429':
          description: Price History account rate limit exceeded
          headers:
            Retry-After:
              schema: { type: integer, minimum: 1 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryError' }
        '503':
          description: HISTORY_UNAVAILABLE or HISTORY_RATE_LIMIT_UNAVAILABLE
          headers:
            Retry-After:
              description: Present as 5 seconds when the History rate-limit authority is unavailable.
              schema: { type: integer, minimum: 1 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HistoryError' }

  /api/v1/products/catalog:
    get:
      operationId: findCatalogProducts
      tags:
        - Product Catalog
      summary: Find PricesAPI product IDs by product text or seller name
      description: >-
        Free for every valid API account. Searches the catalogue PricesAPI
        already holds and returns matching product candidates, each with a
        PricesAPI product ID and a summary of retained Catalog records. Use
        this when you have a product or seller name and need to select a product.
        Text matches do not verify the exact physical variant.


        If you already have an exact PricesAPI or Google catalog ID, call
        Product Snapshot or Price History directly with `id`, the matching
        `id_type=pricesapi` or `id_type=catalog`, and an explicit `market`.
        Neither read requires Catalog first, and History does not require
        Snapshot first. Those reads keep their own credit charges.


        Pass `q` (product text), `seller` (a seller name), or both. At least one
        of them is required; `market` is always required and is never inferred.
        This free discovery operation does not accept `id` or `id_type`.


        A text-only `q` query ranks products observed in the current UTC month
        or previous two months ahead of older matches, with text relevance
        within those groups. Older matching records remain eligible. More
        than one record can refer to a physical product; ranking does not
        establish the correct variant or guarantee future observations.


        The operation returns no price and no offer, never invokes Search or a
        scraper, and never consumes a credit. Coverage is global across
        supported markets; product availability and history depth vary by market.


        `retained` counts raw retained new-condition offer records with a
        recorded price, without collapsing duplicates. History uses different
        inclusion and counting rules. Catalog totals do not establish whether
        History will return offers or what a selected date window will cost.
        Seller and date details cover a narrower period than the retained
        totals and can be empty when older records exist or details are
        unavailable. Empty arrays alone do not mean there is no History.


        The operation uses its own configured per-account bucket, defaulting
        to 30 requests per minute, separate from Search, Product Snapshot and
        Price History.


        `seller` works on its own for retailers of any size, and a seller-only
        query returns that seller's products most recently observed first, paged
        with `page.next_cursor` like any other. Seller-filtered discovery uses
        the current UTC month and previous two months; this does not limit
        the returned retained totals to those months. A `seller` term matching more
        distinct SELLERS than one lookup can search — a short or generic term
        matches tens of thousands — is refused with `400`
        `SELLER_SCAN_LIMIT_EXCEEDED` and a message naming the limit. That refusal
        fails identically on every retry, so it carries no `Retry-After`: use a
        more specific seller name instead.


        A 200 here returns `products`, `page` and `market_catalog` at the TOP
        LEVEL — there is no `success` and no `data` wrapper. A failure does carry
        `success: false` with `error`, so branch on `error` rather than on
        `success`.
      parameters:
        - name: q
          in: query
          required: false
          description: >-
            Product text. 2 to 100 NFKC-normalized characters, at least two of
            them letters or digits, no control characters, and not a URL — use
            Product Search to resolve a retailer URL. Required unless `seller` is
            supplied.
          schema: { type: string, minLength: 2, maxLength: 100 }
        - name: seller
          in: query
          required: false
          description: >-
            Seller name, same shape rule as `q` and likewise not a URL. Required
            unless `q` is supplied. On its own it returns that seller's products
            most recently observed first; combine it with `q` to narrow to
            products whose title also matches.
          schema: { type: string, minLength: 2, maxLength: 100 }
        - name: market
          in: query
          required: true
          description: A supported lowercase two-letter market code. No default, and never inferred.
          schema: { type: string, pattern: '^[a-z]{2}$' }
        - name: limit
          in: query
          required: false
          description: >-
            Products per page, an integer from 1 to 20. Defaults to 10 when
            omitted. A value outside that range returns 400 INVALID_LIMIT naming
            it — this operation refuses the request, it never reduces the value
            to 20.
          schema: { type: integer, minimum: 1, maximum: 20, default: 10 }
        - name: cursor
          in: query
          required: false
          description: >-
            The `page.next_cursor` returned by the previous page. Opaque, bound
            to the q, seller, market, limit and account that issued it, and
            expires 15 minutes after issue. Paging ends at 100 index positions
            per query, after which `next_cursor` is null.
          schema: { type: string, maxLength: 512 }
      responses:
        '200':
          description: Matching products with their PricesAPI IDs and retained observation coverage, including a valid empty page
          headers:
            Cache-Control:
              schema: { type: string, enum: ['private, no-store'] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogProductsResponse' }
        '400':
          description: >-
            INVALID_QUERY (neither q nor seller supplied, or one of them is malformed),
            INVALID_MARKET, INVALID_LIMIT, INVALID_CURSOR, INVALID_PARAMETER, or
            SELLER_SCAN_LIMIT_EXCEEDED
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogError' }
        '401':
          description: MISSING_API_KEY, INVALID_API_KEY_FORMAT, or INVALID_API_KEY
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogError' }
        '405':
          description: METHOD_NOT_ALLOWED. Only GET is supported for this operation.
          headers:
            Allow:
              schema: { type: string, enum: [GET] }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogError' }
        '429':
          description: RATE_LIMIT_EXCEEDED from the lookup's own per-account per-minute bucket
          headers:
            Retry-After:
              schema: { type: integer, minimum: 1 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogError' }
        '503':
          description: PRODUCT_CATALOG_UNAVAILABLE or PRODUCT_CATALOG_RATE_LIMIT_UNAVAILABLE
          headers:
            Retry-After:
              description: Present as 5 seconds on both unavailable responses.
              schema: { type: integer, minimum: 1 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CatalogError' }

  /api/v1/products/{id}/offers:
    get:
      operationId: getProductOffers
      tags:
        - Products
      deprecated: true
      summary: (REMOVED) Fetch offers for a product
      description: >-
        REMOVED. This was the second call of the old two-step flow. Offers are
        now returned inline by `GET /api/v1/products/search`. This endpoint
        always responds `410 Gone` with `error.code = ENDPOINT_GONE`. See the
        migration guide in the API docs.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
      responses:
        '410':
          description: Endpoint removed. Use GET /api/v1/products/search instead.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  headers:
    BulkSearchRetryAfter:
      description: Seconds to wait before another Bulk Search management request.
      schema:
        type: integer
        minimum: 1
    SchedulesRetryAfter:
      description: Seconds to wait before another Schedules management request.
      schema:
        type: integer
        minimum: 1
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        API key sent as a bearer token: `Authorization: Bearer <key>`
        (recommended). A `?api_key=<key>` query parameter still authenticates,
        but it is logged in request URLs and every response to it carries
        `Deprecation` and `Sunset: Sun, 01 Nov 2026 00:00:00 GMT`. Price
        History does not accept it at all — `api_key` is not one of its
        parameters, so the request fails with `400 INVALID_PARAMETER`. Use the
        header.
  parameters:
    ScheduleId:
      in: path
      name: id
      required: true
      description: Positive integer Schedule identifier returned by create or list.
      schema:
        type: integer
        minimum: 1
    SearchJobId:
      in: path
      name: id
      required: true
      description: Search job UUID returned by submit.
      schema:
        type: string
        format: uuid
    ProductSnapshotId:
      name: id
      in: query
      required: true
      description: A PricesAPI decimal ID or opaque Google catalog ID, interpreted only by id_type.
      schema:
        type: string
        minLength: 1
        maxLength: 64
    ProductSnapshotIdType:
      name: id_type
      in: query
      required: true
      description: >-
        The authority of id; never inferred from its value. `catalog` is
        Google's catalog ID (gid): one product variant in one market, not a
        universal product id. The Google product cluster (gpcid) is a
        different namespace, read with `GET /api/v1/product-clusters`.
      schema:
        type: string
        enum: [pricesapi, catalog]
    ProductClusterId:
      name: id
      in: query
      required: true
      description: Opaque Google product-cluster ID (gpcid), 1–64 bytes.
      schema:
        type: string
        pattern: '^[A-Za-z0-9._~-]+$'
        minLength: 1
        maxLength: 64
    ProductClusterIdType:
      name: id_type
      in: query
      required: true
      description: Product clusters currently accept this explicit external authority.
      schema:
        type: string
        enum: [cluster]
    ProductSnapshotMarket:
      name: market
      in: query
      required: true
      description: Supported lower-case market code; there is no default market.
      schema:
        type: string
        pattern: '^[a-z]{2}$'
    ProductSnapshotOffersLimit:
      name: offers_limit
      in: query
      required: false
      description: >-
        Maximum offers returned in `offers[]`, from 0 to 60. Default 60.
        A value of 0 returns the product with no offers. Out-of-range values
        return 400 INVALID_OFFERS_LIMIT; they are not clamped.

        Check `offers_page.limit`, `offers_page.returned` and
        `offers_page.truncated` to understand the returned list. When
        `truncated` is true, more offers exist than this response includes;
        a lowest price computed from this partial list is provisional.
        There is no offers cursor. Offers beyond the maximum of 60 cannot
        be retrieved through pagination.
      schema:
        type: integer
        minimum: 0
        maximum: 60
        default: 60
    ProductSnapshotSellerDomains:
      name: seller_domains
      in: query
      required: false
      style: form
      explode: true
      description: >-
        Filter offers by up to 10 seller hostnames. Repeat the query parameter
        for each domain, for example `seller_domains=amazon.co.uk&seller_domains=argos.co.uk`.
        Supply hostnames, not URLs.

        The API applies IDNA, lowercases the hostname and removes one leading
        `www.`. Other subdomains are preserved. Domains match the offer's
        `seller_domain` exactly after normalization; filtering happens before
        `offers_limit` is applied.
      schema:
        type: array
        minItems: 1
        maxItems: 10
        items:
          type: string
          minLength: 3
          maxLength: 253
    ProductClusterLimit:
      name: limit
      in: query
      required: false
      description: >-
        Page size, an integer from 1 to 100. Defaults to 100 when omitted. A
        value outside that range is a 400 INVALID_CLUSTER_LIMIT — this
        operation's own code, not INVALID_LIMIT — and the request is refused,
        never reduced to 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 100
    ProductClusterCursor:
      name: cursor
      in: query
      required: false
      description: Opaque API-issued continuation bound to this market and cluster.
      schema:
        type: string
  schemas:
    HistoryResponse:
      type: object
      additionalProperties: false
      required: [product, window, offers, page]
      properties:
        product: { $ref: '#/components/schemas/HistoryProduct' }
        window: { $ref: '#/components/schemas/HistoryWindow' }
        offers:
          type: array
          maxItems: 1000
          items: { $ref: '#/components/schemas/HistoryObservedOffer' }
        page: { $ref: '#/components/schemas/HistoryOffersPage' }
    HistoryProduct:
      type: object
      additionalProperties: false
      required: [id, id_type, market, currency]
      properties:
        id: { type: string, pattern: '^[1-9][0-9]{0,9}$' }
        id_type: { type: string, enum: [pricesapi] }
        market: { type: string, pattern: '^[a-z]{2}$' }
        currency: { type: string, pattern: '^[A-Z]{3}$' }
    HistoryWindow:
      type: object
      additionalProperties: false
      description: >-
        Describes the whole requested window, not this page. It is pinned on the
        first page and every continuation echoes it unchanged, so `window` is
        byte-identical on every page of one walk.
      required: [from, to, as_of, days_requested, days_with_offers, times_with_offers, offer_count, first_recorded_at, last_recorded_at]
      properties:
        from: { type: string, format: date }
        to: { type: string, format: date }
        as_of:
          type: string
          format: date-time
          description: The instant the walk was pinned.
        days_requested: { type: integer, minimum: 1 }
        days_with_offers:
          type: integer
          minimum: 0
          description: >-
            Distinct UTC dates in the window that carry at least one offer.
            This is the billing key.
        times_with_offers:
          type: integer
          minimum: 0
          description: Distinct instants in the window at which offers were recorded.
        offer_count:
          type: integer
          minimum: 0
          description: >-
            Offers in the window as counted at the instant page 1 pinned the
            walk, not a running total. An offer committed after that instant
            but stamped at or before `as_of` is still served, on a later page,
            and is not counted here, so the sum of `page.returned` across a
            walk can exceed `offer_count`.
        first_recorded_at: { type: string, format: date-time, nullable: true }
        last_recorded_at: { type: string, format: date-time, nullable: true }
    HistoryObservedOffer:
      type: object
      additionalProperties: false
      required: [recorded_at, seller_id, seller, seller_domain, price, currency, seller_product_url, product_title, stock_status, delivery_info]
      properties:
        recorded_at:
          type: string
          format: date-time
          description: UTC instant PricesAPI recorded this offer, to the millisecond.
        seller_id:
          type: string
          minLength: 1
          description: >-
            Stable PricesAPI seller identifier. The same seller carries the same
            `seller_id` across pages and across products, so it is the key to
            group a per-retailer series on. It is the same identifier
            `GET /api/v1/products/catalog` publishes as `sellers[].id`.
        seller:
          type: string
          nullable: true
          description: Seller display name, or null when no usable name is held.
        seller_domain: { type: string, nullable: true }
        price: { type: number, minimum: 0, exclusiveMinimum: true }
        currency: { type: string, pattern: '^[A-Z]{3}$' }
        seller_product_url:
          type: string
          format: uri
          nullable: true
          description: >-
            The seller product page this offer was read from, so a price can be
            verified rather than trusted. Null when no usable URL is held. This
            is the offer page; the seller home page is Product Snapshot
            `offers[].seller_url`, which is a different URL.
        product_title:
          type: string
          nullable: true
          description: >-
            The listing title the seller published, which is how a bundle or a
            variant is told apart from the catalogue product. Null when the
            seller stated none, and null on every offer whose `recorded_at` is
            at or before 2025-06-30T23:59:59.999Z, because the retained archive
            never stored this value.
        stock_status:
          type: string
          nullable: true
          description: >-
            Availability text the seller published, verbatim and unnormalised.
            Null when the seller stated none, and null on every offer whose
            `recorded_at` is at or before 2025-06-30T23:59:59.999Z, because the
            retained archive never stored this value.
        delivery_info:
          type: string
          nullable: true
          description: >-
            Delivery text the seller published, verbatim and unnormalised. Null
            when the seller stated none, and null on every offer whose
            `recorded_at` is at or before 2025-06-30T23:59:59.999Z, because the
            retained archive never stored this value.
    HistoryOffersPage:
      type: object
      additionalProperties: false
      required: [limit, returned, next_cursor]
      properties:
        limit: { type: integer, minimum: 1, maximum: 1000 }
        returned: { type: integer, minimum: 0 }
        next_cursor:
          type: string
          nullable: true
          maxLength: 3072
          description: >-
            Null on the last page. Otherwise copy this opaque cursor unchanged and
            repeat the original REST query's
            `id`, `id_type`, `market`, `from`, `to` and `limit` exactly.
            If `limit` was omitted, keep it omitted even when the response's
            `page.limit` is 250.
    HistoryError:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success: { type: boolean, enum: [false] }
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            # Declared because this envelope carries the 403 INSUFFICIENT_CREDITS, whose
            # details is an OBJECT. With `additionalProperties: false` above and no
            # `details` here, a client generated from this document rejected the one
            # error it has to act on.
            details: { $ref: '#/components/schemas/CreditRefusalDetails' }
    HistoryAuthError:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success: { type: boolean, enum: [false] }
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            # The three the auth middleware actually emits. AUTHENTICATION_REQUIRED was
            # listed here and is emitted nowhere; MISSING_API_KEY and
            # INVALID_API_KEY_FORMAT are emitted and were absent, so a strict generated
            # client failed on the developer's own first misconfiguration.
            code:
              type: string
              enum: [MISSING_API_KEY, INVALID_API_KEY_FORMAT, INVALID_API_KEY]
            message: { type: string }
            details:
              type: string
              description: >-
                Present on MISSING_API_KEY and INVALID_API_KEY_FORMAT, explaining how
                to present the key. Absent on INVALID_API_KEY, which is the only one of
                the three a correctly-configured integration can see at runtime.
    CatalogProductsResponse:
      type: object
      additionalProperties: false
      required: [products, page, market_catalog]
      properties:
        products:
          type: array
          maxItems: 20
          items: { $ref: '#/components/schemas/CatalogProduct' }
        page: { $ref: '#/components/schemas/CatalogProductsPage' }
        market_catalog: { $ref: '#/components/schemas/CatalogMarket' }
    CatalogMarket:
      type: object
      additionalProperties: false
      required: [market, products, products_at_most, catalog_products, measured_on]
      description: >-
        How much catalogue this market holds, so an empty `products` array can be told
        apart from a query that was simply too specific. Those two are otherwise
        indistinguishable: both are a 200 with no products.


        It is a periodically measured census, not a live count, which is why it carries
        `measured_on` and costs no extra work on this free endpoint.
      properties:
        market:
          type: string
          minLength: 2
          maxLength: 2
          description: The market this census describes, echoing the request.
        products:
          type: integer
          minimum: 0
          nullable: true
          description: >-
            Titled products counted for this market. NULL MEANS NOT COUNTED, never zero —
            if it is null, use `products_at_most` and do not read it as an empty market.
        products_at_most:
          type: integer
          minimum: 0
          description: >-
            Upper bound on titled products for this market. Always present, including when
            `products` is null.
        catalog_products:
          type: integer
          minimum: 0
          description: Documents in the whole catalogue index, across every market.
        measured_on:
          type: string
          format: date
          description: >-
            The date this census was taken. A market grows between censuses, so treat
            these figures as a floor for scale rather than a current total.
    CatalogProduct:
      type: object
      additionalProperties: false
      required: [id, id_type, market, title, image_url, retained, sellers, dates_with_offers]
      properties:
        id:
          type: string
          minLength: 1
          description: The PricesAPI product ID. Pass it to Product Snapshot or Price History as id with id_type=pricesapi and the same market.
        id_type: { type: string, enum: [pricesapi] }
        market: { type: string, pattern: '^[a-z]{2}$' }
        title: { type: string, minLength: 1 }
        image_url: { type: string, nullable: true }
        retained: { $ref: '#/components/schemas/CatalogProductRetained' }
        sellers:
          type: array
          description: >-
            Named sellers with qualifying Catalog records in the available
            detail period, most days-with-offers first. There is no seller-count
            cap, but this narrower period does not guarantee a complete historical
            seller list.
            Empty when `retained.days_with_offers` is 0; it can also be empty
            when only older records exist or seller details are unavailable.
          items: { $ref: '#/components/schemas/CatalogProductSeller' }
        dates_with_offers:
          type: array
          maxItems: 30
          description: >-
            The latest 30 distinct UTC dates with qualifying Catalog records
            in the available detail period, most recent first, or fewer when
            fewer dates are available. This period is narrower than the retained
            totals. A truncated list, not a representative sample or the complete
            set of dates. It can be empty when only older records exist or date
            details are unavailable.
          items: { type: string, format: date }
    CatalogProductRetained:
      type: object
      additionalProperties: false
      required: [offer_count, days_with_offers, first_date_with_offers, last_date_with_offers]
      properties:
        offer_count:
          type: integer
          minimum: 0
          description: >-
            Raw retained new-condition offer records with a recorded price,
            without collapsing duplicates. It was published as `observation_count`;
            it counts records, not distinct instants. History uses different
            inclusion and counting rules, so this is not its offer count or a
            guarantee of offers in a selected History window. Price History's
            `window.times_with_offers` is the distinct-instant count.
        days_with_offers:
          type: integer
          minimum: 0
          description: >-
            Distinct UTC dates carrying at least one qualifying Catalog record,
            not a selected-window History count or quote. THE DAY BOUNDARY IS
            UTC, NOT THE MARKET'S LOCAL DAY, and for a market far from UTC the
            two disagree often: measured on 200,000 `au` observations,
            42.9% fall on a different calendar day in `Australia/Sydney` than
            they do in UTC. Convert from the millisecond instants Price History
            publishes as `recorded_at` if you need local days.
        first_date_with_offers:
          type: string
          format: date
          nullable: true
          description: >-
            Earliest UTC date counted in this Catalog summary, not the earliest
            record History may serve. Same UTC day boundary as `days_with_offers` above.
        last_date_with_offers:
          type: string
          format: date
          nullable: true
          description: >-
            Latest UTC date counted in this Catalog summary, not the latest
            record History may serve. Same UTC day boundary as `days_with_offers` above.
    CatalogProductSeller:
      type: object
      additionalProperties: false
      required: [id, name, first_date_with_offers, last_date_with_offers, days_with_offers]
      properties:
        id:
          type: string
          minLength: 1
          description: >-
            Stable PricesAPI seller identifier. Price History publishes the
            same value as `offers[].seller_id` and Product Snapshot publishes
            it as `offers[].seller_id`, so the three surfaces join on one key.
        name: { type: string, minLength: 1 }
        first_date_with_offers:
          type: string
          format: date
          description: >-
            Earliest UTC date with a qualifying Catalog record for this seller
            within the narrower detail period, not its first-ever offer date.
            UTC day boundary, not the market's local day — see `CatalogProductRetained`.
        last_date_with_offers:
          type: string
          format: date
          description: >-
            Latest UTC date with a qualifying Catalog record for this seller
            within the narrower detail period, not the limit of its History records.
            UTC day boundary, not the market's local day — see `CatalogProductRetained`.
        days_with_offers:
          type: integer
          minimum: 1
          description: >-
            Distinct UTC dates with qualifying Catalog records for this seller
            within the narrower detail period, not a History count.
            UTC day boundary, not the market's local day — see `CatalogProductRetained`.
    CatalogProductsPage:
      type: object
      additionalProperties: false
      required: [limit, returned, next_cursor]
      properties:
        limit: { type: integer, minimum: 1, maximum: 20 }
        returned: { type: integer, minimum: 0, maximum: 20 }
        next_cursor: { type: string, nullable: true }
    CatalogError:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success: { type: boolean, enum: [false] }
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code: { type: string }
            message: { type: string }
            details:
              type: string
              description: >-
                Optional extra context. Present on the MISSING_API_KEY and
                INVALID_API_KEY_FORMAT 401s, where it explains how to present the
                key. Absent on INVALID_API_KEY, which is the only one of the three a
                correctly-configured integration can see at runtime.
    ScheduleActivity:
      type: object
      additionalProperties: false
      required: [state, attempted_at, discovered_product_count]
      properties:
        state:
          type: string
          enum: [awaiting_first_discovery, discovery_complete, discovery_issue]
        attempted_at:
          type: string
          format: date-time
          nullable: true
        discovered_product_count:
          type: integer
          minimum: 0
          nullable: true
    ScheduleCadenceHealth:
      type: object
      additionalProperties: false
      required: [state, last_filled_at, next_due_at, late_by_seconds, missed_windows, latest_outcome, has_snapshot]
      properties:
        state:
          type: string
          enum: [starting, healthy, due, late, missed, paused]
        last_filled_at:
          type: string
          format: date-time
          nullable: true
        next_due_at:
          type: string
          format: date-time
        late_by_seconds: { type: integer, minimum: 0 }
        missed_windows: { type: integer, minimum: 0 }
        latest_outcome:
          type: string
          nullable: true
        has_snapshot: { type: boolean }
    Schedule:
      type: object
      additionalProperties: false
      required: [id, term, market, status, origin, frequency_minutes, slot_cost, paused, evictable, activity, cadence_health, added_at, last_queried_at, query_count]
      properties:
        id: { type: integer, minimum: 1 }
        term:
          type: string
          minLength: 1
          maxLength: 512
          description: >-
            The normalised term, which is what every read surface returns. To
            normalise: trim the input FIRST, then, if what remains is a product
            URL carrying a descriptive slug, take the slug text; lowercase it;
            collapse runs of whitespace to a single space. The leading trim is
            what lets a padded URL still be recognised as a URL. A create echoes the
            trimmed input you sent instead, so the two can differ. A URL with a
            descriptive slug normalises to its slug text and behaves like an
            ordinary text term; a URL with no slug, such as an Amazon `/dp/` or
            eBay `/itm/` link, is accepted and normalises to the raw lowercased
            URL, which is not a searchable term.
        market: { type: string, pattern: '^[a-z]{2}$' }
        status:
          type: string
          enum: [starting, active, recovering, paused]
          description: >-
            A projection of recent activity plus `paused`, not a stored field.
            `recovering` means the most recent discovery attempt ended in a
            status other than ok, no_stale or no_tokens.
        origin:
          type: string
          enum: [auto, manual, bulk]
          description: >-
            How the schedule was created. `bulk` means the create body carried
            two or more terms, `manual` means it carried exactly one. It has
            nothing to do with Async Bulk Search.
        frequency_minutes:
          type: integer
          enum: [60, 360, 720, 1440, 10080]
        slot_cost: { type: integer, minimum: 1 }
        paused: { type: boolean }
        evictable: { type: boolean }
        activity: { $ref: '#/components/schemas/ScheduleActivity' }
        cadence_health: { $ref: '#/components/schemas/ScheduleCadenceHealth' }
        added_at: { type: string, format: date-time }
        last_queried_at: { type: string, format: date-time }
        query_count: { type: integer, minimum: 0 }
    ScheduleFrequencyOption:
      type: object
      additionalProperties: false
      required: [minutes, slot_cost, available, requires_tier]
      properties:
        minutes:
          type: integer
          enum: [60, 360, 720, 1440, 10080]
          description: >-
            Cadence in minutes. The key is `minutes` on this catalogue entry;
            a schedule itself says `frequency_minutes`.
        slot_cost: { type: integer, minimum: 1 }
        available: { type: boolean }
        requires_tier:
          type: string
          enum: [personal, developer, business, enterprise]
    ScheduleStatusCounts:
      type: object
      additionalProperties: false
      required: [active, starting, recovering, paused]
      properties:
        active: { type: integer, minimum: 0 }
        starting: { type: integer, minimum: 0 }
        recovering: { type: integer, minimum: 0 }
        paused: { type: integer, minimum: 0 }
    ScheduleCadenceCounts:
      type: object
      additionalProperties: false
      required: [healthy, due, late, missed, starting, paused]
      properties:
        healthy: { type: integer, minimum: 0 }
        due: { type: integer, minimum: 0 }
        late: { type: integer, minimum: 0 }
        missed: { type: integer, minimum: 0 }
        starting: { type: integer, minimum: 0 }
        paused: { type: integer, minimum: 0 }
    ScheduleActivityPoint:
      type: object
      additionalProperties: false
      required: [date, completed, issues, products_returned]
      properties:
        date: { type: string, format: date }
        completed: { type: integer, minimum: 0 }
        issues: { type: integer, minimum: 0 }
        products_returned: { type: integer, minimum: 0 }
    ScheduleActivitySeries:
      type: object
      additionalProperties: false
      required: [days, points]
      properties:
        days:
          type: integer
          enum: [7, 30, 90]
        points:
          type: array
          items: { $ref: '#/components/schemas/ScheduleActivityPoint' }
    ScheduleAccountMeta:
      type: object
      description: >-
        Caller-owned Schedules capacity and optional analytics. Schedules is in beta.
        Limits and pricing are subject to change.
      required: [tier, slots_cap, frequencies, beta]
      properties:
        tier:
          type: string
          enum: [personal, developer, business, enterprise]
        slots_used: { type: integer, minimum: 0 }
        slots_cap: { type: integer, minimum: 0 }
        slots_available:
          type: integer
          minimum: 0
          description: >-
            Slots this account may still fill. It does not predict a create on
            Personal: Personal accounts draw from one fleet-wide pool of
            schedule slots shared by every Personal customer, 25 of them at
            present, so a create can
            return `cap_reached` while this number is above zero.
        frequencies:
          type: array
          items: { $ref: '#/components/schemas/ScheduleFrequencyOption' }
        beta: { type: boolean, enum: [true] }
        notice:
          type: string
          nullable: true
          example: Schedules is in beta. Limits and pricing are subject to change.
        count: { type: integer, minimum: 0 }
        partial: { type: boolean }
        meta_unavailable: { type: boolean }
        status_counts: { $ref: '#/components/schemas/ScheduleStatusCounts' }
        cadence_counts: { $ref: '#/components/schemas/ScheduleCadenceCounts' }
        activity_series: { $ref: '#/components/schemas/ScheduleActivitySeries' }
    ScheduleListResponse:
      type: object
      required: [success, data, meta]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          additionalProperties: false
          required: [schedules, next_cursor]
          properties:
            schedules:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/Schedule' }
            next_cursor: { type: string, nullable: true }
        meta: { $ref: '#/components/schemas/ScheduleAccountMeta' }
    ScheduleDetailResponse:
      type: object
      required: [success, data, meta]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          additionalProperties: false
          required: [schedule]
          properties:
            schedule: { $ref: '#/components/schemas/Schedule' }
        meta: { $ref: '#/components/schemas/ScheduleAccountMeta' }
    ScheduleCreateTerm:
      type: object
      additionalProperties: false
      required: [term, market]
      properties:
        term: { type: string, minLength: 1, maxLength: 512 }
        market:
          type: string
          pattern: '^[a-z]{2}$'
          description: >-
            Supported lower-case market code. REQUIRED, with no default: a
            schedule created without an explicit market used to be an
            Australian schedule, whatever the caller meant.
        frequency_minutes:
          type: integer
          enum: [60, 360, 720, 1440, 10080]
          default: 1440
          description: >-
            Cadence in minutes, spelled exactly as the read side returns it.
            Omitted, it takes the account default cadence.
    ScheduleCreateRequest:
      type: object
      additionalProperties: false
      required: [terms]
      properties:
        terms:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            oneOf:
              - type: string
                minLength: 1
                maxLength: 512
                description: >-
                  Legacy input form, not a valid enrollment. This item returns
                  status invalid with reason missing_market. Use an object with
                  term and explicit market instead.
              - $ref: '#/components/schemas/ScheduleCreateTerm'
        evictable:
          type: boolean
          default: true
          description: false is accepted only for an operator-reserved account allocation.
    ScheduleCreateResult:
      type: object
      additionalProperties: false
      required: [term, market, status]
      properties:
        term:
          type: string
          maxLength: 512
          description: >-
            Your input, trimmed, echoed back. Every read surface returns the
            normalised term instead, so match rows on the normalised form.
        market: { type: string }
        status:
          type: string
          enum: [added, exists, cap_reached, invalid, rate_limited]
          description: >-
            `cap_reached` means no slot was free. On Personal that can happen
            while `meta.slots_available` still looks healthy, because Personal
            accounts draw from one fleet-wide pool of schedule slots shared by
            every Personal customer, 25 of them at present. `rate_limited` carries no velocity
            meaning: it marks an item that was never attempted, because the 20
            second request budget ran out or a later chunk was interrupted.
            Resubmit those items.
        id: { type: integer, minimum: 1 }
        reason: { type: string }
    ScheduleCreateSummary:
      type: object
      additionalProperties: false
      required: [added, exists, cap_reached, invalid, rate_limited]
      properties:
        added: { type: integer, minimum: 0 }
        exists: { type: integer, minimum: 0 }
        cap_reached: { type: integer, minimum: 0 }
        invalid: { type: integer, minimum: 0 }
        rate_limited: { type: integer, minimum: 0 }
    ScheduleCreateResponse:
      type: object
      required: [success, data, meta]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          additionalProperties: false
          required: [results, summary]
          properties:
            results:
              type: array
              maxItems: 1000
              items: { $ref: '#/components/schemas/ScheduleCreateResult' }
            summary: { $ref: '#/components/schemas/ScheduleCreateSummary' }
        meta: { $ref: '#/components/schemas/ScheduleAccountMeta' }
    ScheduleUpdateRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        frequency_minutes:
          type: integer
          enum: [60, 360, 720, 1440, 10080]
          description: >-
            Cadence in minutes, spelled exactly as the read side returns it.
            The `frequency` alias is gone: one name on the wire.
        paused: { type: boolean }
    ScheduleRemoveResponse:
      type: object
      required: [success, data, meta]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          additionalProperties: false
          required: [id, deleted]
          properties:
            id: { type: integer, minimum: 1 }
            deleted: { type: boolean }
        meta: { $ref: '#/components/schemas/ScheduleAccountMeta' }
    ScheduleSettings:
      type: object
      additionalProperties: false
      required: [auto_enrol, default_frequency_minutes, evict_days]
      properties:
        auto_enrol:
          type: boolean
          default: true
          description: >-
            True on a fresh account. While true, each live Search that returns
            products enrols its term and market as a schedule at
            `default_frequency_minutes`, within the slot cap. PATCH
            /api/v1/schedules/settings with `auto_enrol: false` turns it off;
            existing schedules are untouched.
        default_frequency_minutes:
          type: integer
          enum: [1440, 10080]
        evict_days: { type: integer, minimum: 7, maximum: 90 }
    ScheduleSettingsUpdateRequest:
      type: object
      additionalProperties: false
      minProperties: 1
      properties:
        auto_enrol:
          type: boolean
          description: >-
            False stops live Searches enrolling their terms as schedules;
            existing schedules are untouched. It is true on a fresh account.
        default_frequency_minutes:
          type: integer
          enum: [1440, 10080]
        evict_days: { type: integer, minimum: 7, maximum: 90 }
    ScheduleSettingsResponse:
      type: object
      required: [success, data, meta]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          additionalProperties: false
          required: [settings]
          properties:
            settings: { $ref: '#/components/schemas/ScheduleSettings' }
        meta: { $ref: '#/components/schemas/BetaMeta' }
    ScheduleHistoryRow:
      type: object
      additionalProperties: false
      required: [term, market, query_count, first_queried, last_queried, last_outcome, schedule_state, scheduled_search_id]
      properties:
        term: { type: string, maxLength: 512 }
        market: { type: string, pattern: '^[a-z]{2}$' }
        query_count: { type: integer, minimum: 0 }
        first_queried: { type: string, format: date-time, nullable: true }
        last_queried: { type: string, format: date-time, nullable: true }
        last_outcome:
          type: string
          enum: [clean, degraded, error, cached]
        schedule_state:
          type: string
          enum: [active, paused, cap_reached, not_scheduled]
        scheduled_search_id: { type: integer, minimum: 1, nullable: true }
    ScheduleDemandPoint:
      type: object
      additionalProperties: false
      required: [date, requests, successful_responses, errors]
      properties:
        date: { type: string, format: date }
        requests: { type: integer, minimum: 0 }
        successful_responses: { type: integer, minimum: 0 }
        errors: { type: integer, minimum: 0 }
    ScheduleDemandSeries:
      type: object
      additionalProperties: false
      required: [days, points]
      properties:
        days: { type: integer, minimum: 1, maximum: 90 }
        points:
          type: array
          items: { $ref: '#/components/schemas/ScheduleDemandPoint' }
    ScheduleHistoryMeta:
      type: object
      additionalProperties: false
      required: [count, days, beta]
      properties:
        count: { type: integer, minimum: 0 }
        days: { type: integer, minimum: 1, maximum: 90 }
        beta: { type: boolean, enum: [true] }
        demand_series: { $ref: '#/components/schemas/ScheduleDemandSeries' }
    ScheduleHistoryResponse:
      type: object
      required: [success, data, meta]
      properties:
        success: { type: boolean, enum: [true] }
        data:
          type: object
          additionalProperties: false
          required: [queries, next_cursor, truncated]
          properties:
            queries:
              type: array
              maxItems: 200
              items: { $ref: '#/components/schemas/ScheduleHistoryRow' }
            next_cursor: { type: string, nullable: true }
            truncated:
              type: boolean
              description: >-
                True when the over-fetch window behind this page, `3 * limit +
                1` rows, filled. More groups may then exist than `next_cursor`
                walks to.
        meta: { $ref: '#/components/schemas/ScheduleHistoryMeta' }
    ScheduleErrorResponse:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success: { type: boolean, enum: [false] }
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - INVALID_BODY
                - INVALID_JSON
                - PAYLOAD_TOO_LARGE
                - TOO_MANY_TERMS
                - INVALID_CURSOR
                - INVALID_ID
                - INVALID_LIMIT
                - INVALID_DAYS
                - INVALID_PARAMETER
                - INVALID_STATUS
                - INVALID_ORIGIN
                - INVALID_QUERY
                - INVALID_FREQUENCY
                - INVALID_ACTIVITY_DAYS
                - FREQUENCY_NOT_AVAILABLE
                - EVICTABLE_NOT_PERMITTED
                - CAP_REACHED
                - SCHEDULE_NOT_FOUND
                - SCHEDULE_ROUTE_NOT_FOUND
                - SCHEDULES_READONLY
                - SCHEDULES_UNAVAILABLE
                - RATE_LIMIT_EXCEEDED
                - MISSING_API_KEY
                - INVALID_API_KEY_FORMAT
                - INVALID_API_KEY
                - SUBSCRIPTION_CANCELLED
                - NOT_FOUND
                - API_KEY_IN_REQUEST_DATA
            message: { type: string }
            details: { type: string, nullable: true }
            limit:
              type: integer
              minimum: 1
              description: >-
                Present on RATE_LIMIT_EXCEEDED: the number of Schedules management
                requests per window this account may make. Throttle to this rather than
                guessing from the refusal.
            window:
              type: string
              description: >-
                Present on RATE_LIMIT_EXCEEDED: the period `limit` applies over.
                `Retry-After` on the same response gives the seconds remaining in the
                current window, which is the value a poller should actually sleep on.
    BetaMeta:
      type: object
      additionalProperties: false
      required:
        - beta
      properties:
        beta:
          type: boolean
          enum: [true]
    BulkSearchSubmission:
      type: object
      additionalProperties: false
      required:
        - searches
      properties:
        searches:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/BulkSearchInput'
    BulkSearchInput:
      type: object
      additionalProperties: false
      required:
        - q
        - market
      properties:
        q:
          type: string
          minLength: 1
          description: >-
            Product name or retailer URL. The runtime trims it, rejects control
            characters, and limits the normalized value to 400 UTF-8 bytes.
          example: Sony WH-1000XM5
        market:
          type: string
          description: >-
            Case-insensitive supported market code for global coverage. The
            effective request returns it lowercase. REQUIRED, with no default:
            an item written for one market must not silently run against
            another.
          example: us
        limit:
          type: integer
          minimum: 1
          maximum: 60
          default: 3
          description: >-
            Candidate products for this item, defaulting to 3 when omitted. The
            maximum is your plan's — Personal 3, Developer 10, Business and
            Enterprise 60 — the same ceiling `GET /api/v1/products/search`
            applies. A value above it is refused with 400 INVALID_ITEM naming
            the item index and the range; unlike interactive Search a batch item
            is refused rather than shortened, because a batch has no response
            meta in which a clamp could be announced. 0, a negative, a
            non-integer, and a string such as `"5"` are refused the same way.
        offers_limit:
          type: integer
          minimum: 1
          maximum: 60
          default: 15
          description: >-
            Offers per candidate product for this item. A JSON integer from 1 to
            60, defaulting to 15 when omitted. A value above 60 is refused with
            400 INVALID_ITEM naming the item index and the range — the same
            behaviour as `GET /api/v1/products/search`. 0, a negative, a
            non-integer, and a string such as `"60"` are refused the same way.
        max_age:
          type: integer
          minimum: 0
          maximum: 9007199254740991
          description: Maximum acceptable cached or scheduled data age, in seconds.
        no_cache:
          type: boolean
          default: false
          description: true bypasses cache reads and requires a live Search.
    BulkSearchEffectiveRequest:
      allOf:
        - $ref: '#/components/schemas/BulkSearchInput'
        - type: object
          required:
            - q
            - market
            - limit
            - offers_limit
          properties:
            q:
              type: string
            market:
              type: string
            limit:
              type: integer
              minimum: 1
            offers_limit:
              type: integer
              minimum: 1
    BulkSearchJob:
      type: object
      additionalProperties: false
      required:
        - id
        - cancel_requested_at
        - expires_at
        - created_at
        - total
        - pending
        - running
        - succeeded
        - empty
        - failed
        - cancelled
        - reserved
        - consumed
        - refunded
      properties:
        id:
          type: string
          format: uuid
        cancel_requested_at:
          type: string
          format: date-time
          nullable: true
        expires_at:
          type: string
          format: date-time
          description: >-
            Execution cutoff. No pending item starts after this time; unfinished
            pending items are cancelled and refunded, while already-running work
            may finish. Status and results remain readable after the cutoff in the
            current Beta, but are not durable storage; collect them promptly.
        created_at:
          type: string
          format: date-time
        total:
          type: integer
          minimum: 0
        pending:
          type: integer
          minimum: 0
        running:
          type: integer
          minimum: 0
        succeeded:
          type: integer
          minimum: 0
        empty:
          type: integer
          minimum: 0
        failed:
          type: integer
          minimum: 0
        cancelled:
          type: integer
          minimum: 0
        reserved:
          type: integer
          minimum: 0
          description: Number of items with reserved credit, not the credit amount.
        consumed:
          type: integer
          minimum: 0
          description: Number of items with consumed credit, not the credit amount.
        refunded:
          type: integer
          minimum: 0
          description: Number of items with refunded credit, not the credit amount.
    BulkSearchItem:
      type: object
      additionalProperties: false
      required:
        - id
        - input_index
        - request
        - state
        - credit_state
        - attempt
        - response
        - error_code
        - started_at
        - completed_at
      properties:
        id:
          type: string
          description: Decimal item identifier.
        input_index:
          type: integer
          minimum: 0
          description: Zero-based position in the submitted searches array.
        request:
          $ref: '#/components/schemas/BulkSearchEffectiveRequest'
        state:
          type: string
          enum: [pending, running, succeeded, empty, failed, cancelled]
        credit_state:
          type: string
          enum: [reserved, consumed, refunded]
        attempt:
          type: integer
          minimum: 0
          description: Number of successful worker claims.
        response:
          type: object
          nullable: true
          description: >-
            Normal Search success or failure envelope for succeeded, empty, or
            failed items; null while pending or running and after cancellation.
          oneOf:
            - $ref: '#/components/schemas/SearchResponse'
            - $ref: '#/components/schemas/BulkSearchItemError'
        error_code:
          type: string
          nullable: true
          description: Machine-readable Search or worker code for a failed item.
        started_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
    BulkSearchItemError:
      type: object
      additionalProperties: false
      required:
        - success
        - error
        - meta
      properties:
        success:
          type: boolean
          enum: [false]
        error:
          $ref: '#/components/schemas/Error/properties/error'
        meta:
          description: >-
            Search failures retain their latency metadata. Failures synthesized
            by the asynchronous worker carry an empty metadata object.
          oneOf:
            - $ref: '#/components/schemas/FailureMeta'
            - type: object
              additionalProperties: false
              maxProperties: 0
    BulkSearchResultsPage:
      type: object
      additionalProperties: false
      required:
        - items
        - next_cursor
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/BulkSearchItem'
        next_cursor:
          type: string
          nullable: true
          description: Exact opaque cursor for the next page; null on the final page.
    BulkSearchSubmitResponse:
      type: object
      additionalProperties: false
      required: [success, data, meta]
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          type: object
          additionalProperties: false
          required: [job, replayed]
          properties:
            job:
              $ref: '#/components/schemas/BulkSearchJob'
            replayed:
              type: boolean
        meta:
          $ref: '#/components/schemas/BetaMeta'
    BulkSearchJobResponse:
      type: object
      additionalProperties: false
      required: [success, data, meta]
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          type: object
          additionalProperties: false
          required: [job]
          properties:
            job:
              $ref: '#/components/schemas/BulkSearchJob'
        meta:
          $ref: '#/components/schemas/BetaMeta'
    BulkSearchResultsResponse:
      type: object
      additionalProperties: false
      required: [success, data, meta]
      properties:
        success:
          type: boolean
          enum: [true]
        data:
          $ref: '#/components/schemas/BulkSearchResultsPage'
        meta:
          $ref: '#/components/schemas/BetaMeta'
    SearchResponse:
      type: object
      description: Successful search envelope.
      required:
        - success
        - data
        - meta
      properties:
        success:
          type: boolean
          enum: [true]
          description: Always true on a 200.
        data:
          $ref: '#/components/schemas/SearchData'
        meta:
          $ref: '#/components/schemas/Meta'
    SearchData:
      type: object
      required:
        - query
        - market
        - products
      properties:
        query:
          type: string
          description: The normalised query that was searched.
        market:
          type: string
          description: The market that was searched, lowercase, echoing the request.
          example: au
        products:
          type: array
          description: >-
            Ranked candidate products. Empty when Google had nothing for the
            query (still a 200).
          items:
            $ref: '#/components/schemas/Candidate'
    ProductRef:
      type: object
      additionalProperties: false
      required: [id, id_type, market]
      description: >-
        Exact product identity scoped to the stated market. Carry all three
        fields to Product Snapshot or Price History. This identifies a product
        in this response, not a permanent lifecycle or future-availability promise.
        A product cluster is not a product reference.
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 64
          pattern: '^[A-Za-z0-9._~-]+$'
          description: >-
            For pricesapi, the decimal representation of a positive int32 product
            ID (1 through 2147483647). For catalog, the exact 1–64 character ASCII
            catalog ID, without trimming or other normalization.
        id_type:
          type: string
          enum: [pricesapi, catalog]
        market:
          type: string
          enum: [us, au, gb, ca, ie, nz, de, fr, it, es, pt, nl, be, at, ch, se, no, dk, fi, pl, cz, sk, hu, ro, gr, ua, tr, il, ae, sa, za, in, jp, kr, tw, hk, sg, my, th, id, ph, vn, mx, br, ar, cl, co]
          description: Supported lowercase market, exactly matching the Search envelope.
    Candidate:
      type: object
      description: >-
        One ranked candidate product, with its seller offers inline. Card-level
        signals (rating, reviews, delivery) live here, NOT on each offer.
      required:
        - position
        - pid
        - product_ref
        - cluster_id
        - catalog_id
        - title
        - image
        - headline_price
        - headline_currency
        - condition_label
        - source
        - multi_store
        - rating
        - reviews
        - delivery
        - tags
        - nearby_distance_km
        - offers
        - offerCount
      properties:
        position:
          type: integer
          description: 1-based rank of this candidate in the result set.
        pid:
          type: integer
          nullable: true
          description: >-
            PricesAPI product-record ID (null if not catalogued). Reuse it with
            the same market for later reads. Check the title and seller listing
            to confirm the model and variant.
        product_ref:
          description: >-
            Present on every newly served candidate. Uses a valid numeric positive
            int32 pid first, otherwise the exact valid catalog_id. Null when neither
            identifies a product or the envelope market is missing or unsupported.
            Recomputed from current identifiers and market even for stored results.
            Consumers may accept an absent field during a rolling release.
          oneOf:
            - $ref: '#/components/schemas/ProductRef'
            - type: object
              nullable: true
              enum: [null]
        cluster_id:
          type: string
          nullable: true
          description: >-
            Google's product-cluster ID (gpcid), the variant grouping: the
            same value in every country. It is the value
            `GET /api/v1/product-clusters` takes as `id` under
            `id_type=cluster`.
        catalog_id:
          type: string
          nullable: true
          description: >-
            Google's catalog ID (gid) for this candidate: one product variant
            in one market, not a universal product id. It is the value the
            other operations in this contract take as `id` under
            `id_type=catalog`.
        title:
          type: string
          nullable: true
          description: Product title.
        image:
          type: string
          nullable: true
          description: Product image URL.
        headline_price:
          type: number
          nullable: true
          description: >-
            The price printed on the Google Shopping card. It is NOT a minimum
            and NOT necessarily any offer in `offers[]`: it can exceed every
            offer below it — a published example carries a headline of 329.99
            above a 248 offer from the same seller — and it can describe a
            different variant (a different capacity, colour or bundle) of the
            same product. To quote a price a customer can buy at, read
            `offers[]`. On a degraded response (`offerCount: 0`) it is the only
            price in the object, and it is still not an offer.
        headline_currency:
          type: string
          nullable: true
          description: ISO currency code for `headline_price`.
          example: AUD
        condition_label:
          type: string
          nullable: true
          description: >-
            The condition text printed on the product card, verbatim and
            unnormalised — `New`, `Used`, `Pre-owned`, `Refurbished`, `Open Box`,
            `Renewed`. Null on the ordinary case: the card only carries it when a
            listing is not plain new, so a NON-null value is the signal worth
            reading. It is a LABEL, not an enum, and it describes the CARD, not the
            individual sellers in `offers[]` — a `Used` card is a used listing, but
            a null one does not certify that every seller under it is new.
          example: Pre-owned
        source:
          type: string
          nullable: true
          description: Source/store label shown on the card.
        multi_store:
          type: boolean
          description: True when the product is offered by multiple stores.
        rating:
          type: number
          nullable: true
          description: >-
            Rating reported on the shopping card (0-5), not per seller offer.
            The response does not identify the underlying review sources or
            confirm their variant scope.
        reviews:
          type: integer
          nullable: true
          description: >-
            Review count reported on the shopping card, not per seller offer.
            Counts expanded from abbreviations may be approximate. The response
            does not identify the underlying review sources or confirm their
            variant scope.
        delivery:
          type: string
          nullable: true
          description: Card-level delivery text.
        tags:
          type: array
          description: Card badges/labels (e.g. sale, free delivery).
          items:
            type: string
        nearby_distance_km:
          type: number
          nullable: true
          description: Distance to a nearby physical store, when applicable.
        offers:
          type: array
          description: >-
            Seller offers for this candidate. Empty when the offers stage was
            degraded (see meta.degraded).
          items:
            $ref: '#/components/schemas/Offer'
        offerCount:
          type: integer
          description: >-
            Number of offers in `offers` (after truncation to `offers_limit`).
    Offer:
      type: object
      description: >-
        One seller offer, returned inline on each Candidate. Carries per-seller
        product_title, stock_status, and delivery_info. Rating and review count
        reported on the shopping card live on the Candidate, not the offer.
      required:
        - seller
        - seller_url
        - price
        - currency
        - product_title
        - stock_status
        - delivery_info
        - url
      properties:
        seller:
          type: string
          description: Seller / store name.
        seller_url:
          type: string
          nullable: true
          description: Seller origin URL (homepage), parsed from the offer URL.
        price:
          type: number
          description: Offer price.
        currency:
          type: string
          nullable: true
          description: ISO currency code for the offer price.
          example: AUD
        product_title:
          type: string
          nullable: true
          description: The seller's listing title for this offer.
          example: Sony WH-1000XM5 Wireless Headphones
        stock_status:
          type: string
          nullable: true
          description: Availability text reported by the seller.
          example: In stock
        delivery_info:
          type: string
          nullable: true
          description: Delivery / shipping text reported by the seller (null if not stated).
          example: Free delivery
        url:
          type: string
          description: Direct link to the seller's product page for this offer.
    Meta:
      type: object
      description: Per-call timing, provenance, and result-shape flags.
      required:
        - latency_ms
        - raw_count
        - gid_bearing_count
      properties:
        latency_ms:
          type: integer
          description: Total server-side latency for the call, in milliseconds.
        raw_count:
          type: integer
          description: Raw candidate count seen before filtering.
        gid_bearing_count:
          type: integer
          description: Candidates that carried a Google catalog ID (gid), published as catalog_id.
        cache_source:
          type: string
          enum:
            - redis
            - db
            - miss
            - coalesced
            - scheduled
          description: >-
            Which tier served the result: `redis` (hot in-memory tier), `db`
            (durable Postgres tier), `miss` (cold live scrape), `coalesced`
            (shared an in-flight live scrape), or `scheduled` (scheduled store).
        cache_age_s:
          type: integer
          description: >-
            Age of the served durable entry, in seconds. Present ONLY on a `db`
            cache hit.
        discover_ms:
          type: integer
          description: Time spent in the discover stage (milliseconds).
        products_ms:
          type: integer
          description: Time spent in the offers/products stage (milliseconds).
        blocked:
          type: boolean
          description: Derived bimodal silent-fail signal from the discover stage.
        degraded:
          type: boolean
          description: >-
            True when discover succeeded but the offers stage failed — candidates
            are returned with empty `offers`.
        limit_capped:
          type: boolean
          description: >-
            True when Product Search accepts a requested `limit` above your
            plan's maximum and returns that maximum instead. Present with
            `limit_max`. Other operations validate their own documented limit
            ranges.
        limit_max:
          type: integer
          description: >-
            The product limit actually applied, which is your plan's maximum —
            Personal 3, Developer 10, Business and Enterprise 60. Present
            alongside `limit_capped` when the requested limit was capped, and
            the reliable way to read your own ceiling.
          example: 3
        scraped_at:
          type: string
          format: date-time
          description: >-
            Time the returned data was scraped. Always present for scheduled
            store serves; optional on other sources while provenance rollout is
            gated, and absent when an older cache blob has no trustworthy time.
        next_refresh_at:
          type: string
          format: date-time
          description: >-
            Next scheduled cycle time. Present only on scheduled-store serves
            and may be in the past when a cycle is overdue.
        served_stale:
          type: boolean
          description: >-
            True when a live failure or degraded scrape fell back to scheduled
            data. Never present for an explicit `max_age` or truthy `no_cache`.
        schedule_state:
          type: string
          enum:
            - active
            - paused
            - cap_reached
            - not_scheduled
          description: >-
            Optional caller-scoped Schedules state. It is never a term-global
            signal and is absent when schedule peeking is disabled or unavailable.
    FailureMeta:
      type: object
      description: Timing metadata available on Search error envelopes.
      required:
        - latency_ms
      properties:
        latency_ms:
          type: integer
          description: Total server-side latency before the error, in milliseconds.
    Error:
      type: object
      description: Error envelope.
      required:
        - success
        - error
      properties:
        success:
          type: boolean
          enum: [false]
          description: Always false on an error.
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: >-
                Machine-readable error code. The enum covers synchronous Search,
                Async Bulk Search management, authentication, quota, and retired
                endpoint responses.
              enum:
                - MISSING_QUERY
                - INVALID_LIMIT
                - INVALID_OFFERS_LIMIT
                - INVALID_MARKET
                - INVALID_MAX_AGE
                - INVALID_PARAMETER
                - MISSING_IDEMPOTENCY_KEY
                - INVALID_IDEMPOTENCY_KEY
                - INVALID_BODY
                - INVALID_ITEM_COUNT
                - INVALID_ITEM
                - INVALID_JSON
                - INVALID_JOB_ID
                - INVALID_CURSOR
                - PAYLOAD_TOO_LARGE
                - MISSING_API_KEY
                - INVALID_API_KEY_FORMAT
                - INVALID_API_KEY
                - SUBSCRIPTION_CANCELLED
                - CREDITS_EXCEEDED
                - MONTHLY_LIMIT_EXCEEDED
                - INSUFFICIENT_CREDITS
                - RATE_LIMIT_EXCEEDED
                - NOT_FOUND
                - JOB_NOT_FOUND
                - ENDPOINT_GONE
                - IDEMPOTENCY_CONFLICT
                - SCRAPER_UNAVAILABLE
                - SCRAPER_TIMEOUT
                - SCRAPER_BUSY
                - SCRAPER_CIRCUIT_OPEN
                - WORKER_EXCEPTION
                - WORKER_RETRY_EXHAUSTED
                - RESULT_TOO_LARGE
                - SERVICE_UNAVAILABLE
                - BULK_SEARCH_ADMISSION_PAUSED
                - BULK_SEARCH_UNAVAILABLE
                - DB_ERROR
                - SEARCH_FAILED
                - INTERNAL_ERROR
            message:
              type: string
              description: Human-readable error message.
            details:
              description: >-
                Optional extra context. Usually a string. On INVALID_PARAMETER
                from Search it is an object carrying `unknown` (the keys this
                operation does not accept, in the order they were sent) and
                `accepted` (every key it does accept). On CREDITS_EXCEEDED,
                on MONTHLY_LIMIT_EXCEEDED, and on the RATE_LIMIT_EXCEEDED that
                replaces CREDITS_EXCEEDED once an over-quota key retries more than
                ten times a minute, it is an object carrying `credits_used`,
                `credits_included`, `resets_at` and `upgrade_url`. `resets_at` is
                a known renewal instant, or null when no date is available.
                Null can mean one-time evaluation credits or a paid account
                whose next renewal date is not recorded; read the accompanying
                message for that distinction rather than inferring it from null.
                MONTHLY_LIMIT_EXCEEDED — the Search refusal raised when what is
                left will not cover the whole 10-credit Search — also carries
                `credits_required` and `credits_remaining`. CREDITS_EXCEEDED does
                not: it is answered before routing, where the cost of the request
                is not yet known.
              oneOf:
                - type: string
                  nullable: true
                - type: object
                  nullable: true
                  properties:
                    unknown:
                      type: array
                      items: { type: string }
                    accepted:
                      type: array
                      items: { type: string }
                    credits_used:
                      type: integer
                      description: Credits consumed in the current allowance.
                    credits_included:
                      type: integer
                      description: Credits the allowance includes.
                    credits_required:
                      type: integer
                      description: >-
                        What the refused request would have cost. Present only on
                        refusals raised where the cost is known.
                    credits_remaining:
                      type: integer
                      description: >-
                        What the allowance still holds. Present alongside
                        `credits_required`.
                    resets_at:
                      type: string
                      format: date-time
                      nullable: true
                      description: >-
                        Known renewal date, or null when no date is available.
                        Null alone does not mean the credits never renew; read
                        the accompanying message.
                    upgrade_url:
                      type: string
                      format: uri
                      description: Where to add credits.
            supported:
              type: array
              description: >-
                On INVALID_MARKET, the list of supported lowercase market
                codes.
              items:
                type: string
            replacement:
              type: string
              description: On ENDPOINT_GONE, the replacement endpoint path.
        meta:
          $ref: '#/components/schemas/FailureMeta'
    CreditRefusalDetails:
      type: object
      additionalProperties: false
      required: [credits_used, credits_included, resets_at, upgrade_url]
      description: >-
        `error.details` on a credit refusal: the account's credit position at the
        moment it was refused, so the caller can tell "add credits" from "wait for
        the renewal" from "ask for less" without a second call.
      properties:
        credits_used:
          type: integer
          minimum: 0
          description: Credits consumed in the current allowance.
        credits_included:
          type: integer
          minimum: 0
          description: Credits the allowance includes.
        resets_at:
          type: string
          format: date-time
          nullable: true
          description: >-
            Known renewal date, or null when no date is available. Read the
            accompanying message to distinguish one-time credits from a paid plan
            whose next renewal date is not yet recorded.
        upgrade_url:
          type: string
          format: uri
          description: Where to add credits.
        credits_required:
          type: integer
          minimum: 1
          description: >-
            What the refused request would have cost. Present whenever the refusal
            was raised by a route that knows its own price — which is every
            INSUFFICIENT_CREDITS 403. For a per-item read this is what was ASKED
            FOR (the requested span, or the requested item count), not the smaller
            amount a sparse result would have kept after the refund.
        credits_remaining:
          type: integer
          minimum: 0
          description: >-
            What the allowance still holds. Present alongside `credits_required`.
    ProductSnapshotErrorCode:
      type: string
      enum:
        - INVALID_PRODUCT_ID
        - INVALID_PRODUCT_CLUSTER_ID
        - INVALID_ID_TYPE
        - INVALID_MARKET
        - INVALID_OFFERS_LIMIT
        - INVALID_SELLER_DOMAIN
        - INVALID_CLUSTER_LIMIT
        - INVALID_CLUSTER_CURSOR
        - INVALID_PARAMETER
        - INVALID_BATCH
        - INVALID_BATCH_ITEM
        - INVALID_JSON
        - PAYLOAD_TOO_LARGE
        - PRODUCT_NOT_FOUND
        - PRODUCT_CLUSTER_NOT_FOUND
        - METHOD_NOT_ALLOWED
        # The 403 every priced read here can answer with. It was absent while the
        # operations already documented a 403 response.
        - INSUFFICIENT_CREDITS
        - RATE_LIMIT_EXCEEDED
        - SNAPSHOT_RATE_LIMIT_UNAVAILABLE
        # The global authentication boundary answers before any of these operations
        # runs, so its codes are part of their published surface too.
        - MISSING_API_KEY
        - INVALID_API_KEY_FORMAT
        - INVALID_API_KEY
        - API_KEY_IN_REQUEST_DATA
        - SNAPSHOT_UNAVAILABLE
        - SNAPSHOT_DATA_INVALID
        - SNAPSHOT_RESPONSE_TOO_LARGE
    ProductSnapshotErrorResponse:
      type: object
      additionalProperties: false
      required: [success, error]
      properties:
        success:
          type: boolean
          enum: [false]
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              $ref: '#/components/schemas/ProductSnapshotErrorCode'
            message:
              type: string
            details:
              description: >-
                Optional extra context, and it is NOT always a string. On the
                MISSING_API_KEY and INVALID_API_KEY_FORMAT 401s it is a string
                explaining how to present the key. On the 403 INSUFFICIENT_CREDITS it
                is the credit-position object. Absent on INVALID_API_KEY, which
                is the only one of the three a correctly-configured integration can
                see at runtime.
              oneOf:
                - type: string
                - $ref: '#/components/schemas/CreditRefusalDetails'
            input_index:
              type: integer
              minimum: 0
              maximum: 99
              nullable: true
    ProductSnapshotProduct:
      type: object
      additionalProperties: false
      required: [id, id_type, market, title, brand, image_url, category_id, catalog_id, status, offline_at, specs]
      properties:
        id:
          type: string
          pattern: '^[1-9][0-9]{0,9}$'
          description: Canonical PricesAPI product ID from 1 to 2147483647.
        id_type:
          type: string
          enum: [pricesapi]
        market:
          type: string
          pattern: '^[a-z]{2}$'
        title:
          type: string
          nullable: true
        brand:
          type: string
          nullable: true
        image_url:
          type: string
          format: uri
          nullable: true
        category_id:
          type: integer
          nullable: true
        catalog_id:
          type: string
          pattern: '^[A-Za-z0-9._~-]+$'
          minLength: 1
          maxLength: 64
          description: >-
            Google's catalog ID (gid) for this product in this market: one
            variant, not a universal product id. It was published as a
            one-element `aliases` array, which implied a set that never had
            more than one member.
        status:
          type: string
          enum: [active, offline]
        offline_at:
          type: string
          format: date-time
          nullable: true
        specs:
          type: array
          nullable: true
          minItems: 1
          description: >-
            The product's attribute table — name and value pairs such as
            `{"name": "Storage Capacity", "value": "128 GB, 256 GB"}` — as observed
            by our scrapers, in the order they were recorded.
            It describes the PRODUCT FAMILY, not one variant and not one offer: a
            phone's "Colour" can list every colour and its "Storage Capacity" every
            capacity, so do not read a value as the attribute of the item a seller
            is pricing — check each offer's `title` for that.
            Names and values are display strings, not normalised units or a fixed
            vocabulary: the set of names varies by category and by product, and the
            same attribute can be spelled differently on two products.
            `null` means we hold no attribute table for this product. It is never an
            empty array, and a table is returned whole or not at all.
            Not available on Search: take a candidate's `product_ref` (or `pid`)
            and read it here.
          items:
            type: object
            additionalProperties: false
            required: [name, value]
            properties:
              name:
                type: string
                minLength: 1
              value:
                type: string
                minLength: 1
    ProductSnapshotObservation:
      type: object
      additionalProperties: false
      required: [state, reason, observed_at, age_seconds, stale_after_seconds, stale]
      properties:
        state:
          type: string
          enum: [observed, partial, unobserved]
        reason:
          type: string
          enum: [no_valid_offers, not_observed, null]
          nullable: true
        observed_at:
          type: string
          format: date-time
          nullable: true
        age_seconds:
          type: integer
          minimum: 0
          nullable: true
        stale_after_seconds:
          type: integer
          enum: [93600]
        stale:
          type: boolean
          nullable: true
    ProductSnapshotOffer:
      type: object
      additionalProperties: false
      description: >-
        `offers[]` is sorted, not arbitrary: by availability rank first —
        in_stock, preorder, backorder, unknown, out_of_stock — then by price
        ascending within a rank.
      required: [seller_id, seller, seller_url, seller_domain, price, currency, availability, availability_raw, title, delivery_info, url, observed_at]
      properties:
        seller_id:
          type: string
          minLength: 1
          description: >-
            Stable PricesAPI seller identifier, the same value Price History
            publishes as `offers[].seller_id` and the catalogue lookup
            publishes as `sellers[].id`, so the three surfaces join on one key.
        seller:
          type: string
        seller_url:
          type: string
          format: uri
          nullable: true
          description: The seller home page. The offer page is `url`, below.
        seller_domain:
          type: string
          nullable: true
          description: Canonical hostname derived from the retained offer URL; null when that URL is unavailable or unsafe.
        price:
          type: number
          minimum: 0
          exclusiveMinimum: true
        currency:
          type: string
          pattern: '^[A-Z]{3}$'
          description: ISO 4217 currency for the explicit market.
        availability:
          type: string
          enum: [in_stock, preorder, backorder, unknown, out_of_stock]
        availability_raw:
          type: string
          nullable: true
        title:
          type: string
          nullable: true
        delivery_info:
          type: string
          nullable: true
        url:
          type: string
          format: uri
          nullable: true
        observed_at:
          type: string
          format: date-time
    ProductSnapshotOffersPage:
      type: object
      additionalProperties: false
      required: [limit, returned, truncated]
      properties:
        limit:
          type: integer
          minimum: 0
          maximum: 60
        returned:
          type: integer
          minimum: 0
          maximum: 60
        truncated:
          type: boolean
          description: >-
            True when `offers_limit` cut the list. Truncation drops the
            worst-ranked offers, so the cheapest in_stock offer is always
            present.
    ProductSnapshotResponse:
      type: object
      additionalProperties: false
      required: [product, observation, offers, offers_page]
      properties:
        product:
          $ref: '#/components/schemas/ProductSnapshotProduct'
        observation:
          $ref: '#/components/schemas/ProductSnapshotObservation'
        offers:
          type: array
          maxItems: 60
          items:
            $ref: '#/components/schemas/ProductSnapshotOffer'
        offers_page:
          $ref: '#/components/schemas/ProductSnapshotOffersPage'
        seller_domains_filter:
          $ref: '#/components/schemas/ProductSnapshotSellerDomainsFilter'
    ProductSnapshotSellerDomainsFilter:
      type: object
      additionalProperties: false
      required: [requested, matched]
      properties:
        requested:
          type: array
          minItems: 1
          maxItems: 10
          items:
            type: string
            minLength: 3
            maxLength: 253
        matched:
          type: array
          maxItems: 10
          items:
            type: string
            minLength: 3
            maxLength: 253
    ProductSnapshotBatchItem:
      type: object
      additionalProperties: false
      required: [id, id_type, market]
      properties:
        id:
          type: string
          minLength: 1
          maxLength: 64
        id_type:
          type: string
          enum: [pricesapi, catalog]
        market:
          type: string
          pattern: '^[a-z]{2}$'
        offers_limit:
          type: integer
          minimum: 0
          maximum: 60
          default: 60
          description: >-
            Maximum offers returned for this item, an integer from 0 to 60 sent
            as a JSON number. Defaults to 60 when omitted, and 0 is valid. A
            value outside that range, or the same value sent as a string such as
            `"3"`, is a 400 INVALID_OFFERS_LIMIT carrying the item's
            `input_index` — the request is refused, never shortened to 60.
        seller_domains:
          type: array
          minItems: 1
          maxItems: 10
          items:
            type: string
            minLength: 3
            maxLength: 253
    ProductSnapshotBatchRequest:
      type: object
      additionalProperties: false
      required: [items]
      properties:
        items:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: '#/components/schemas/ProductSnapshotBatchItem'
    ProductSnapshotBatchFound:
      type: object
      additionalProperties: false
      required: [input_index, status, product, observation, offers, offers_page]
      properties:
        input_index:
          type: integer
          minimum: 0
          maximum: 99
        status:
          type: string
          enum: [found]
        product:
          $ref: '#/components/schemas/ProductSnapshotProduct'
        observation:
          $ref: '#/components/schemas/ProductSnapshotObservation'
        offers:
          type: array
          maxItems: 60
          items:
            $ref: '#/components/schemas/ProductSnapshotOffer'
        offers_page:
          $ref: '#/components/schemas/ProductSnapshotOffersPage'
        seller_domains_filter:
          $ref: '#/components/schemas/ProductSnapshotSellerDomainsFilter'
    ProductSnapshotBatchNotFound:
      type: object
      additionalProperties: false
      required: [input_index, status, error]
      properties:
        input_index:
          type: integer
          minimum: 0
          maximum: 99
        status:
          type: string
          enum: [not_found]
        error:
          type: object
          additionalProperties: false
          required: [code, message]
          properties:
            code:
              type: string
              enum: [PRODUCT_NOT_FOUND]
            message:
              type: string
    ProductSnapshotBatchResponse:
      type: object
      additionalProperties: false
      required: [results, summary]
      properties:
        results:
          type: array
          minItems: 1
          maxItems: 100
          items:
            oneOf:
              - $ref: '#/components/schemas/ProductSnapshotBatchFound'
              - $ref: '#/components/schemas/ProductSnapshotBatchNotFound'
        summary:
          type: object
          additionalProperties: false
          required: [requested, distinct_lookups, found, not_found]
          properties:
            requested:
              type: integer
              minimum: 1
              maximum: 100
            distinct_lookups:
              type: integer
              minimum: 1
              maximum: 100
            found:
              type: integer
              minimum: 0
              maximum: 100
            not_found:
              type: integer
              minimum: 0
              maximum: 100
    ProductClusterMember:
      type: object
      additionalProperties: false
      required: [id, id_type, catalog_id, title, brand, image_url, status, offline_at]
      properties:
        id:
          type: string
          pattern: '^[1-9][0-9]{0,9}$'
          description: Canonical PricesAPI product ID from 1 to 2147483647.
        id_type:
          type: string
          enum: [pricesapi]
        catalog_id:
          type: string
          pattern: '^[A-Za-z0-9._~-]+$'
          minLength: 1
          maxLength: 64
          description: Google's catalog ID (gid) for this member product in this market.
        title:
          type: string
          nullable: true
        brand:
          type: string
          nullable: true
        image_url:
          type: string
          format: uri
          nullable: true
        status:
          type: string
          enum: [active, offline]
        offline_at:
          type: string
          format: date-time
          nullable: true
    ProductCluster:
      type: object
      additionalProperties: false
      required: [id, id_type, market]
      properties:
        id:
          type: string
          pattern: '^[A-Za-z0-9._~-]+$'
          minLength: 1
          maxLength: 64
        id_type:
          type: string
          enum: [cluster]
        market:
          type: string
          pattern: '^[a-z]{2}$'
    ProductClusterPage:
      type: object
      additionalProperties: false
      required: [limit, returned, next_cursor]
      properties:
        limit:
          type: integer
          minimum: 1
          maximum: 100
        returned:
          type: integer
          minimum: 0
          maximum: 100
        next_cursor:
          type: string
          nullable: true
    ProductClusterPageResponse:
      type: object
      additionalProperties: false
      required: [cluster, products, page]
      properties:
        cluster:
          $ref: '#/components/schemas/ProductCluster'
        products:
          type: array
          maxItems: 100
          items:
            $ref: '#/components/schemas/ProductClusterMember'
        page:
          $ref: '#/components/schemas/ProductClusterPage'
