openapi: 3.1.0
info:
  title: Bidvise Catalogue API
  version: "2.0.0"
  summary: Catalogue reads and atomic item creation and updates for one auction house.
  description: |
    Catalogue reads and atomic item creation and updates for a single auction house. Your API key
    determines which house you see; there is no cross-tenant access.

    ## Conventions

    - **Identifiers** are prefixed and self-describing: `auc_`, `ses_`, `lot_`,
      `itm_`, `img_`, `sel_`, `cat_`, `mkr_`, `dlc_`, `inc_`, `con_`. Passing a session id where a lot id belongs is a clean
      404, not a confusing miss.
    - **Timestamps** are RFC 3339 / ISO 8601 with an offset.
    - **Errors** are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) Problem
      Details, served as `application/problem+json`.
    - **Money** is always an object: integer **minor units** plus an ISO 4217
      code. `{ "amount": 12000, "currency": "EUR" }` is €120,00. Never a float,
      never a bare number, never a display symbol.
    - **Collections are cursor-paginated.** Pass the `next_cursor` you were
      given; a cursor is a position in the ordering, so records added while you
      walk a list never duplicate or skip what you have already read. A `Link`
      header ([RFC 8288](https://www.rfc-editor.org/rfc/rfc8288)) carries the
      same next page.
    - **Relationships appear as ids**, so the graph is always visible. Inflate
      one in place with `?expand[]=item`. There are no parallel "detailed"
      endpoints.
    - **Flat text** uses the house default language. Items also expose explicit
      stored `translations`, without manufacturing copies from locale fallbacks.

    ## The model

    An **auction** is the event a house announces ("Najaar 2026"). It contains
    one or more **sessions** — the sittings, each with its own opening and
    closing. A session holds **lots**, numbered for the sale. A lot offers an
    **item**, the physical object, which carries the **images**.
  contact:
    name: Bidvise
    url: https://bidvise.com
servers:
  - url: https://app.bidvise.com/api/v2
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Auctions
    description: The events a house announces.
  - name: Sessions
    description: Sittings within an auction.
  - name: Lots
    description: Numbered offerings within a session.
  - name: Items
    description: The physical objects, and their images.

  - name: Sellers
    description: Seller profiles used to reconcile consignors.
  - name: Categories
    description: House-owned catalogue categories.

  - name: Makers
    description: Artists, brands and workshops.
  - name: DeliveryClasses
    description: Delivery and collection instructions.

  - name: IncrementStrategies
    description: Existing bid increment configurations.

  - name: Consignments
    description: Existing consignment references, without private terms.

  - name: ImageManagement
    description: Import reconciliation, including private images.

paths:
  /:
    get:
      tags: [Discovery]
      summary: What this API serves
      operationId: discover
      description: The resources available and where the documentation lives. Needs a valid key and nothing more.
      responses:
        '200':
          description: The API's entry point.
          content:
            application/json:
              schema:
                type: object
                properties:
                  name:          { type: string, example: Bidvise Catalogue API }
                  version:       { type: string, example: "2" }
                  documentation: { type: string, format: uri }
                  openapi:       { type: string, format: uri }
                  resources:
                    type: object
                    additionalProperties: { type: string, format: uri }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /auctions:
    post:
      tags: [Auctions]
      summary: Create a draft auction
      operationId: createDraftAuction
      description: >-
        Requires auctions.write and auctions.read. Creates an unpublished event
        with no sessions, lots or bidding history, using the house's existing
        model defaults. A required case-sensitive external_reference is unique
        within the house. Retries return409 instead of overwriting; after an
        uncertain response use listAuctions with external_reference to reconcile.
        Publication, nested sessions and copying an existing auction are not
        accepted by this operation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [title, external_reference]
              properties:
                title: { type: string, minLength: 1, maxLength: 255, description: Non-blank auction title. }
                external_reference: { type: string, minLength: 1, maxLength: 255, description: Non-blank case-sensitive import reference unique within the house. }
                description: { type: [string, "null"] }
      responses:
        '201':
          description: An unpublished empty auction. The slug is derived by the existing model.
          headers:
            Location:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Auction' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: This house already has an auction with the reference. Look it up and reconcile.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: Invalid input or unsupported field; nothing is created.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
    get:
      tags: [Auctions]
      summary: List auctions
      operationId: listAuctions
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/published'
        - name: external_reference
          in: query
          description: Exact, case-sensitive non-blank importer reference within this house. Combine with published=false to inspect drafts.
          schema: { type: string, minLength: 1 }
        - name: expand
          in: query
          description: "Inflate a relationship in place. Supported: `sessions`."
          schema: { type: string, example: sessions }
      responses:
        '200':
          description: A page of auctions.
          headers:
            Link:                { $ref: '#/components/headers/Link' }
            RateLimit-Limit:     { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset:     { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Auction' }
                  has_more: { type: boolean, example: true }
                  next_cursor: { type: [string, "null"], example: "cur_MGY4Yz…" }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /auctions/{id}:
    patch:
      tags: [Auctions]
      summary: Update draft auction text
      operationId: updateDraftAuction
      description: >-
        Requires auctions.write and auctions.read. Updates only the title and
        description of an unpublished auction. Omitted fields are unchanged;
        description=null clears it. An empty or unchanged patch is a no-op.
        The external reference, slug, publication, settings and sessions remain
        unchanged. Published auctions return 409. This is not a publication or
        session-settings operation. Readback and the update share one transaction.
      parameters:
        - $ref: '#/components/parameters/id'
        - name: expand
          in: query
          schema: { type: string, example: sessions }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                title: { type: string, minLength: 1, maxLength: 255, pattern: '\S' }
                description: { type: [string, "null"] }
            example: { title: "September 2026 — updated catalogue" }
      responses:
        '200':
          description: The updated draft auction.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Auction' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The auction is published; no fields are changed.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: Invalid input or unsupported field; no fields are changed.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
    get:
      tags: [Auctions]
      summary: Fetch one auction
      operationId: getAuction
      parameters:
        - $ref: '#/components/parameters/id'
        - name: expand
          in: query
          schema: { type: string, example: sessions }
      responses:
        '200':
          description: The auction.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Auction' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
        '404': { $ref: '#/components/responses/NotFound' }

  /sessions:
    post:
      tags: [Sessions]
      summary: Create a draft session
      operationId: createDraftSession
      description: >-
        Requires auctions.write and auctions.read. Creates an empty unpublished
        session within this house's auction, without lots, bids or history.
        Customer bidding starts disabled; staff enable it deliberately after review.
        The title is stored and read in the normalized house default language.
        Timestamps are truncated to whole seconds before validation and storage;
        ordering comparisons use those normalized instants.
        Existing sibling/site configuration defaults apply before explicit input;
        explicit dates are required and the opposite kind's dates are cleared.
        Explicit increment_strategy selection additionally requires bidding_step_configurations.read.
        An omitted increment_strategy inherits the sibling's strategy, falling
        back to the house's first strategy; no available strategy returns422.
        Null is not accepted as input.
        Omitted soft-close values retain the inherited defaults (normally 120).
        external_reference is case-sensitive and unique across this house's
        sessions, including sessions in different auctions. A duplicate returns
        409 without overwriting. Reconcile an uncertain result with listSessions
        and external_reference. This operation does not publish or update sessions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionCreate'
      responses:
        '201':
          description: An unpublished empty session.
          headers:
            Location:
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409':
          description: A session already uses this house-scoped external reference; reconcile instead of overwriting.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: Invalid input or house reference; nothing is created.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
    get:
      tags: [Sessions]
      summary: List sessions
      operationId: listSessions
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - $ref: '#/components/parameters/published'
        - name: external_reference
          in: query
          description: Exact, case-sensitive import reference within this house. Must not be blank.
          schema: { type: string, minLength: 1, pattern: '^(?![\s\S]*\u0000)(?![\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$)' }
        - name: auction
          in: query
          description: Only sessions belonging to this auction.
          schema: { type: string, example: "auc_3f2a1c04-9b7e-4d51-8a63-1e5c7d90b482" }
        - name: expand
          in: query
          description: "Supported: `auction`."
          schema: { type: string, example: lots }
      responses:
        '200':
          description: A page of sessions.
          headers:
            Link:                { $ref: '#/components/headers/Link' }
            RateLimit-Limit:     { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset:     { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Session' }
                  has_more: { type: boolean, example: true }
                  next_cursor: { type: [string, "null"], example: "cur_MGY4Yz…" }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /sessions/{id}:
    patch:
      tags: [Sessions]
      summary: Correct an empty draft session title, settings or timing
      operationId: updateSession
      description: |
        Requires auctions.write and auctions.read. Updates title in the house's
        default language and supplied settings; other translations and omitted settings are preserved. Only unpublished,
        unfinished sessions without lots (including archived lots) qualify.
        A hidden session with its publication flag enabled is also rejected.
        Identity, import reference, parent, kind and customer-bidding controls remain unchanged.
        To replace timing, send opens_at together with a complete timed or live block matching
        the existing session kind, using the same date/integer rules as creation. A partial
        schedule, null, or both blocks is rejected. Omitted timed extension values retain
        their current effective values. Timing omitted entirely is preserved.
        Populated-session edits are outside this operation. Repeating values or sending an empty object is a no-op.
        The write and its readback share one transaction. PUT is not supported.
      parameters:
        - $ref: '#/components/parameters/id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                title: { type: string, minLength: 1, maxLength: 255, pattern: '\S' }
                settings: { $ref: '#/components/schemas/SessionWriteSettings' }
                opens_at: { type: string, format: date-time, description: Explicit offset; required with a timing block. }
                timed: { $ref: '#/components/schemas/SessionTimedWrite' }
                live: { $ref: '#/components/schemas/SessionLiveWrite' }
              oneOf:
                - not:
                    anyOf:
                      - required: [opens_at]
                      - required: [timed]
                      - required: [live]
                - required: [opens_at, timed]
                  not: { required: [live] }
                - required: [opens_at, live]
                  not: { required: [timed] }
            example: { title: "Session I — silver", settings: { buyer_premium_percentage: "28.50" } }
      responses:
        '200':
          description: The updated draft session.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The session is published, finished or contains current/archived lots; nothing is changed.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: Invalid input or unsupported field; nothing is changed.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
    get:
      tags: [Sessions]
      summary: Fetch one session
      operationId: getSession
      parameters:
        - $ref: '#/components/parameters/id'
        - name: expand
          in: query
          schema: { type: string, example: lots }
      responses:
        '200':
          description: The session.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Session' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
        '404': { $ref: '#/components/responses/NotFound' }

  /lots:
    get:
      tags: [Lots]
      summary: List lots
      operationId: listLots
      description: |
        Lots across the house's sessions. Narrow with `session` or `auction`,
        filter an exact `lot_number`, and choose depth with `expand[]` — this replaces the separate "detailed"
        and "by lot number" endpoints of the previous API version.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: session
          in: query
          description: Only lots in this session.
          schema: { type: string, example: "ses_7c1d5e88-2a34-4f19-9b0c-6d2e8f4a1357" }
        - name: auction
          in: query
          description: Only lots in this auction.
          schema: { type: string, example: "auc_3f2a1c04-9b7e-4d51-8a63-1e5c7d90b482" }
        - name: lot_number
          in: query
          description: |
            Exact lot number, combined with the other filters. Numbering is
            unique per session, not per auction: use `session` to disambiguate
            matches across sessions. Omit `active` to include inactive lots.
          schema: { type: integer, format: int32, minimum: -2147483648, maximum: 2147483647, example: 1000 }
        - name: active
          in: query
          description: Filter the lot's active flag. False includes legacy unset values; this does not check session publication or customer bidding eligibility.
          schema: { type: boolean }
        - name: expand
          in: query
          description: "Supported: `item`, `images`, `session`."
          schema: { type: string, example: "item,images" }
      responses:
        '200':
          description: A page of lots.
          headers:
            Link:                { $ref: '#/components/headers/Link' }
            RateLimit-Limit:     { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset:     { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Lot' }
                  has_more: { type: boolean, example: true }
                  next_cursor: { type: [string, "null"], example: "cur_MGY4Yz…" }
        '500':
          description: Item identity or translation integrity is unavailable, or an expanded session reports Resource identity unavailable for its increment strategy. Contact support.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
  /lots/{id}:
    patch:
      tags: [Lots]
      summary: Set lot visibility before bidding opens
      operationId: setLotActive
      description: |
        Requires lots.write and lots.read. Changes only active, through the existing
        lot model callbacks. The session must be unfinished, have opens_at at least five minutes in the future
        and no bids, including on archived lots. Lead time is checked again after
        readback, before committing. Timed sessions also require a valid
        first closing time, positive group size and interval. A repeated unchanged
        value is a no-op, including after opening. Boolean false includes legacy null.
        Timed end times are recomputed by the existing database-backed job after
        commit; a 200 response confirms the flag, not completion of that job. Poll
        lot reads for scheduled closing times. Customer-bidding controls and
        auction/session publication are not changed. Moving, renumbering, replacing
        the item, creating lots and editing a running sale are not supported here.
      parameters:
        - $ref: '#/components/parameters/id'
        - name: expand
          in: query
          schema: { type: string, example: "item,session" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [active]
              properties:
                active: { type: boolean }
      responses:
        '200':
          description: The flag is persisted, or already had the requested value. Timed schedule recomputation may still be pending.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Lot' }
        '409':
          description: The session has opened, is finished, has bids or has no valid schedule; no visibility change was made.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: Invalid input or existing model rules reject the lot; no visibility change was made.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '500':
          description: Readback identity or translation integrity is unavailable; the visibility write and any newly queued recomputation roll back.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
    get:
      tags: [Lots]
      summary: Fetch one lot
      operationId: getLot
      parameters:
        - $ref: '#/components/parameters/id'
        - name: expand
          in: query
          schema: { type: string, example: "item,images" }
      responses:
        '200':
          description: The lot.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Lot' }
        '500':
          description: Item identity or translation integrity is unavailable, or an expanded session reports Resource identity unavailable for its increment strategy. Contact support.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /items:
    get:
      tags: [Items]
      summary: List items
      operationId: listItems
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: external_reference
          in: query
          description: |
            Exact, case-sensitive importer reference (the existing v1
            `tracking_identifier`). Non-blank and unique within this house.
            NUL bytes are invalid.
            No trimming or partial matching. A missing reference returns an
            empty collection, never a match from another house.
          schema:
            type: string
            minLength: 1
            # Same Unicode whitespace set as Rails String#blank?.
            pattern: '[^\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]'
            not: { pattern: '\u0000' }
            example: "2026_9-1000"
        - name: expand
          in: query
          description: "Supported: `images`."
          schema: { type: string, example: images }
      responses:
        '200':
          description: A page of items.
          headers:
            Link:                { $ref: '#/components/headers/Link' }
            RateLimit-Limit:     { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset:     { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Item' }
                  has_more: { type: boolean, example: true }
                  next_cursor: { type: [string, "null"], example: "cur_MGY4Yz…" }
        '500': { $ref: '#/components/responses/ItemIntegrityUnavailable' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
    post:
      tags: [Items]
      summary: Create an item atomically
      operationId: createItem
      description: |
        Requires lots.write and lots.read. Attaching a seller requires seller_profiles.read;
        attaching categories requires categories.read. No reference write permissions are needed.
        Creates metadata and translations in one transaction, without creating a lot or publishing.
        Supply an external_reference and a nonblank title, either flat or in the house default locale.
        Repeated or archived reserved references return 409; never silently update an existing item.
      parameters:
        - name: expand
          in: query
          schema: { type: string, enum: [images] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ItemCreate' }
            example:
              external_reference: "2026_9-1000"
              title: "Zilveren kan"
              translations:
                en: { title: "Silver jug" }
              estimate:
                low: { amount: 10000, currency: EUR }
                high: { amount: 20000, currency: EUR }
      responses:
        '201':
          description: The created item. Location contains its API path.
          headers:
            Location:
              schema: { type: string, example: /api/v2/items/itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047 }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Item' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/ReferenceConflict' }
        '422': { $ref: '#/components/responses/InvalidItem' }
        '500': { $ref: '#/components/responses/ItemIntegrityUnavailable' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /items/{id}:
    get:
      tags: [Items]
      summary: Fetch one item
      operationId: getItem
      parameters:
        - $ref: '#/components/parameters/itemId'
        - name: expand
          in: query
          schema: { type: string, example: images }
      responses:
        '200':
          description: The item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Item' }
        '500': { $ref: '#/components/responses/ItemIntegrityUnavailable' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

    patch:
      tags: [Items]
      summary: Update supplied item fields atomically
      operationId: updateItem
      description: |
        Same permissions as create. Omitted fields remain unchanged. Null clears nullable text,
        seller or estimates; categories: [] clears categories. Default-language title and
        external_reference cannot be cleared. Each supplied translation updates only its supplied fields.
        A partial estimate is checked against the other stored bound. Existing consignment rules apply.
        PATCH locks the item; overlapping writes are applied serially, with the last supplied value winning.
      parameters:
        - $ref: '#/components/parameters/itemId'
        - name: expand
          in: query
          schema: { type: string, enum: [images] }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ItemWrite' }
            example:
              description: "Nieuwe beschrijving"
              translations:
                en: { description: "Updated description" }
      responses:
        '200':
          description: The updated item.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Item' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/ReferenceConflict' }
        '422': { $ref: '#/components/responses/InvalidItem' }
        '500': { $ref: '#/components/responses/ItemIntegrityUnavailable' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /sellers:
    get:
      tags: [Sellers]
      summary: List sellers
      operationId: listSellers
      description: Requires `seller_profiles.read`. No relationship expansions are supported.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: seller_number
          in: query
          description: Exact seller business number. Archived sellers are excluded. Duplicate matches remain paginated.
          schema: { type: integer, minimum: -2147483648, maximum: 2147483647, example: 1000 }
      responses:
        '200':
          description: A page of sellers.
          headers:
            Link: { $ref: '#/components/headers/Link' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Seller' }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /sellers/{id}:
    get:
      tags: [Sellers]
      summary: Fetch one seller
      operationId: getSeller
      description: Requires `seller_profiles.read`. Accepts a prefixed ID or bare UUID. No filters or expansions.
      parameters:
        - $ref: '#/components/parameters/selId'
      responses:
        '200':
          description: The seller.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Seller' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /categories:
    get:
      tags: [Categories]
      summary: List categories
      operationId: listCategories
      description: Requires `categories.read`. No relationship expansions are supported.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: slug
          in: query
          description: Exact, case-sensitive slug. No trimming or partial matching. Whitespace-only and NUL input are rejected. Duplicate slugs remain paginated.
          schema:
            type: string
            minLength: 1
            # Same Unicode whitespace set as Rails String#blank?.
            pattern: '[^\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]'
            not: { pattern: '\u0000' }
            example: "zilver"
      responses:
        '200':
          description: A page of categories.
          headers:
            Link: { $ref: '#/components/headers/Link' }
            RateLimit-Limit: { $ref: '#/components/headers/RateLimitLimit' }
            RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
            RateLimit-Reset: { $ref: '#/components/headers/RateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Category' }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /categories/{id}:
    get:
      tags: [Categories]
      summary: Fetch one category
      operationId: getCategory
      description: Requires `categories.read`. Accepts a prefixed ID or bare UUID. No filters or expansions.
      parameters:
        - $ref: '#/components/parameters/catId'
      responses:
        '200':
          description: The category.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Category' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

  /makers:
    get:
      tags: [Makers]
      summary: List makers
      operationId: listMakers
      description: "Requires lots.read. Includes active and inactive makers; slug matches exactly. Text uses the house default language."
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: slug
          in: query
          schema: { type: string, minLength: 1 }
        - name: active
          in: query
          schema: { type: boolean }
      responses:
        '200':
          description: A cursor-paginated page of references.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Maker' }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /makers/{id}:
    get:
      tags: [Makers]
      summary: Fetch one Maker
      operationId: getMaker
      description: Requires lots.read. Only records belonging to this API key's house can be read.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: "mkr_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
      responses:
        '200':
          description: The reference.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Maker' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

  /delivery_classes:
    get:
      tags: [DeliveryClasses]
      summary: List delivery classes
      operationId: listDeliveryClasses
      description: "Requires lots.read. Delivery and collection instructions, not priced shipping methods. Ordered by position and public UUID."
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: A cursor-paginated page of references.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DeliveryClass' }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /delivery_classes/{id}:
    get:
      tags: [DeliveryClasses]
      summary: Fetch one DeliveryClass
      operationId: getDeliveryClass
      description: Requires lots.read. Only records belonging to this API key's house can be read.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: "dlc_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
      responses:
        '200':
          description: The reference.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DeliveryClass' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

  /increment_strategies:
    get:
      tags: [IncrementStrategies]
      summary: List increment strategies
      operationId: listIncrementStrategies
      description: "Requires bidding_step_configurations.read. Excludes deleted strategies. Exact name filter; ordered by creation time and public UUID. Names are not unique; use returned IDs."
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: name
          in: query
          description: Exact, case-sensitive non-blank name. May match multiple strategies.
          schema: { type: string }
      responses:
        '200':
          description: A cursor-paginated page of references.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/IncrementStrategy' }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /increment_strategies/{id}:
    get:
      tags: [IncrementStrategies]
      summary: Fetch one IncrementStrategy
      operationId: getIncrementStrategy
      description: Requires bidding_step_configurations.read. Only records belonging to this API key's house can be read.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: "inc_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
      responses:
        '200':
          description: The reference.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/IncrementStrategy' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

  /consignments:
    get:
      tags: [Consignments]
      summary: List consignments
      operationId: listConsignments
      description: "Requires seller_profiles.read. Excludes deleted consignments. Exact reference and seller filters; ordered by creation time and public UUID. Does not expose notes or commission terms."
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: reference
          in: query
          description: Exact, case-sensitive non-blank reference.
          schema: { type: string }
        - name: seller
          in: query
          description: Same-house sel_ identity or bare UUID.
          schema: { type: string }
      responses:
        '200':
          description: A cursor-paginated page of references.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Consignment' }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
  /consignments/{id}:
    get:
      tags: [Consignments]
      summary: Fetch one Consignment
      operationId: getConsignment
      description: Requires seller_profiles.read. Only records belonging to this API key's house can be read.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, example: "con_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
      responses:
        '200':
          description: The reference.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/Consignment' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

  /items/{item_id}/images:
    post:
      tags: [ImageManagement]
      summary: Upload a new item image
      operationId: uploadItemImage
      description: >-
        Requires lots.write and lots.read. Create-only multipart upload with a
        case-sensitive external_reference unique within this item. Stores the
        source before committing the image and its existing processing job in
        the shared database transaction. Derivative processing is asynchronous.
        An image becomes public and featured when the item has no featured image
        under existing image rules; otherwise it starts private. Use gallery replacement to set visibility
        and order. Never replaces an existing source. On a conflict or an uncertain
        response, look up external_reference before retrying. URLs do not prove
        derivative readiness. Remote URL fetching is not supported.
      parameters:
        - name: item_id
          in: path
          required: true
          schema: { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              additionalProperties: false
              required: [file, external_reference]
              properties:
                file:
                  type: string
                  format: binary
                  description: "1 byte to 25 MiB. Existing uploader accepts jpg, jpeg, png, gif, webp, heic and heif; the server must be able to decode the image."
                external_reference:
                  type: string
                  minLength: 1
                  maxLength: 255
                  description: "Non-blank, exact case-sensitive reference within this item."
      responses:
        '201':
          description: Source stored and processing job committed; derivatives may still be pending.
          headers:
            Location:
              description: Management URL for the created image.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/ManagedItemImage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: External reference already reserved; reconcile with the management lookup.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: Invalid file, reference or unsupported field.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
        '503':
          description: Upload storage or transactional queue unavailable. Look up the reference before retrying an uncertain response.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
    get:
      tags: [ImageManagement]
      summary: List managed item images
      operationId: listItemImages
      description: "Requires lots.read. Includes private and processing images of this same-house item. URLs and processing=false do not prove derivative availability or success. Public item.images expansion is unchanged."
      parameters:
        - name: item_id
          in: path
          required: true
          schema: { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
        - name: external_reference
          in: query
          description: Exact, case-sensitive non-blank upload reference within this item.
          schema: { type: string }
      responses:
        '200':
          description: "Management metadata; lists use creation time and UUID cursor order, independent of gallery position."
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ManagedItemImage' }
                  has_more: { type: boolean }
                  next_cursor: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

  /items/{item_id}/images/{id}:
    delete:
      tags: [ImageManagement]
      summary: Delete a stored item image
      operationId: deleteItemImage
      description: >-
        Requires lots.read and lots.write. Send no request body. Removes this image
        through the existing destroy and CarrierWave cleanup path, including private
        or queued images. Other gallery metadata is unchanged; deleting the featured
        image does not select a replacement. Repeat deletion returns 404. Pending jobs
        are not purged; an existing worker finding the deleted image exits without
        recreating it. Running processing and rotation jobs clean up files they
        recreate after deletion, including on processing failure; DELETE does not
        wait for those jobs. Storage cleanup happens after database commit: a 503 can leave
        the image record absent, and 404 does not prove physical cleanup succeeded.
        A cleanup fallback stores the image ID and filename in the same transaction
        as deletion. Workers also queue a fallback before removing recreated files.
        Cleanup retries through the existing carrierwave queue without the image row;
        exhausted retries need operator intervention.
      parameters:
        - name: item_id
          in: path
          required: true
          schema: { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        - name: id
          in: path
          required: true
          schema: { type: string, example: "img_4a184bb3-a36a-4bac-898b-e242ec2b98a1" }
      responses:
        '204':
          description: Image deleted and existing cleanup callbacks completed. No response body.
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Concurrent database change. Read the gallery before retrying.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
        '503':
          description: Deletion or storage cleanup uncertain. Read the image before retrying; missing records do not prove storage cleanup. Queued cleanup retries independently; exhausted retries need operator intervention.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
    get:
      tags: [ImageManagement]
      summary: Fetch a managed item image
      operationId: getItemImage
      description: "Requires lots.read. Includes private and processing images of this same-house item. URLs and processing=false do not prove derivative availability or success. Public item.images expansion is unchanged."
      parameters:
        - name: item_id
          in: path
          required: true
          schema: { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        - name: id
          in: path
          required: true
          schema: { type: string, example: "img_4a184bb3-a36a-4bac-898b-e242ec2b98a1" }
      responses:
        '200':
          description: "Management metadata; lists use creation time and UUID cursor order, independent of gallery position."
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/ManagedItemImage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

  /items/{item_id}/images/{id}/reprocess:
    post:
      tags: [ImageManagement]
      summary: Request regeneration of stored image derivatives
      operationId: reprocessItemImage
      description: >-
        Requires lots.write and lots.read. Sends the existing image worker a request
        to regenerate derivatives from the existing source, without replacing the
        source or changing gallery visibility/order. Accepts only an empty JSON object.
        Returns 409 while the existing processing flag is set; it does not bypass
        automatic retries, reset an exhausted job or repair an unknown stale flag.
        After an uncertain response, read the image status before retrying. A new
        request after completion is a new regeneration, not an idempotency-key replay.
      parameters:
        - name: item_id
          in: path
          required: true
          schema: { type: string }
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
            example: {}
      responses:
        '202':
          description: Work flag and existing database job committed together; no proof of derivative completion.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/ManagedItemImage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The image already has its processing flag set; no job added or retry budget changed.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '422':
          description: Invalid request fields or missing source identifier.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }
        '503':
          description: Database queue unavailable; read status before retrying an uncertain response.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }

  /items/{item_id}/gallery:
    put:
      tags: [ImageManagement]
      summary: Replace an item's public gallery
      operationId: replaceItemGallery
      description: >-
        Requires lots.write and lots.read. Atomically replaces public visibility,
        order and featured image for one same-house item, with at most 500 stored
        images. Images omitted from the ordered list become private; none are
        deleted. featured must be explicitly null or the first listed image.
        An empty list with featured=null hides the whole gallery. No file storage,
        processing flags, upload references or jobs change. Repeating the same
        request preserves timestamps when the stored metadata already matches.
        Concurrent v2 replacements serialize; the last successful replacement wins.
        This is not an optimistic edit/merge API: read and reconcile before
        replacing a gallery that staff may also be editing.
      parameters:
        - name: item_id
          in: path
          required: true
          schema: { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ItemGalleryWrite' }
            example:
              images: ["img_4a184bb3-a36a-4bac-898b-e242ec2b98a1"]
              featured: "img_4a184bb3-a36a-4bac-898b-e242ec2b98a1"
      responses:
        '200':
          description: Public gallery snapshot built within the write transaction.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/ItemGallery' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Invalid gallery or an image not belonging to this item. No changes are committed.
          content:
            application/problem+json:
              schema: { $ref: '#/components/schemas/Problem' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/ReferenceIdentityUnavailable' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Your API key, sent as `Authorization: Bearer <key>`."

  parameters:
    itemId:
      name: id
      in: path
      required: true
      description: An item identifier or bare UUID.
      schema: { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
    id:
      name: id
      in: path
      required: true
      description: A prefixed identifier, e.g. `lot_b48e0a19-5c72-4d83-91af-3e6b2c07d514`.
      schema: { type: string, example: "lot_b48e0a19-5c72-4d83-91af-3e6b2c07d514" }
    selId:
      name: id
      in: path
      required: true
      description: A `sel_` identifier or bare UUID, e.g. `sel_b48e0a19-5c72-4d83-91af-3e6b2c07d514`.
      schema: { type: string, example: "sel_b48e0a19-5c72-4d83-91af-3e6b2c07d514" }
    catId:
      name: id
      in: path
      required: true
      description: A `cat_` identifier or bare UUID, e.g. `cat_b48e0a19-5c72-4d83-91af-3e6b2c07d514`.
      schema: { type: string, example: "cat_b48e0a19-5c72-4d83-91af-3e6b2c07d514" }
    limit:
      name: limit
      in: query
      description: Results per page.
      schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
    cursor:
      name: cursor
      in: query
      description: The `next_cursor` from a previous response. Opaque — pass it back unchanged.
      schema: { type: string, example: "cur_MGY4Yz…" }
    published:
      name: published
      in: query
      description: |
        Only what is (or is not) visible on the house's website. A session must
        be published AND not hidden to count as visible.
      schema: { type: boolean }

  headers:
    RateLimitLimit:
      description: Requests allowed per minute for this key.
      schema: { type: integer, example: 300 }
    RateLimitRemaining:
      description: Requests left in the current window.
      schema: { type: integer, example: 299 }
    RateLimitReset:
      description: Seconds until the window resets.
      schema: { type: integer, example: 42 }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, example: 42 }
    Link:
      description: |
        RFC 8288 link to the next page, `rel="next"`. Absent on the last page.
        Cursor pagination walks forward only, so no `first`, `prev` or `last`
        relation is emitted.
      schema: { type: string }

  responses:
    ReferenceConflict:
      description: The external reference is reserved by an active or archived item; no changes committed.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    InvalidItem:
      description: Invalid item input or an existing catalogue rule; no changes committed.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    RateLimited:
      description: |
        The key has exceeded its requests-per-minute allowance. `Retry-After`
        says how many seconds until the window resets.
      headers:
        Retry-After:        { $ref: '#/components/headers/RetryAfter' }
        RateLimit-Limit:    { $ref: '#/components/headers/RateLimitLimit' }
        RateLimit-Remaining: { $ref: '#/components/headers/RateLimitRemaining' }
        RateLimit-Reset:    { $ref: '#/components/headers/RateLimitReset' }
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    BadRequest:
      description: |
        A parameter could not be used as sent: a `limit` that is not a whole
        number, a `cursor` this API did not issue, an `expand` value the resource
        does not support, a boolean filter that is not `true` or `false`, or a
        filter id of the wrong kind. The `detail` names the parameter and what
        was expected. Nothing is guessed on the caller's behalf.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Unauthorized:
      description: The API key is missing or unknown.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    Forbidden:
      description: |
        The key is valid but lacks the permission this endpoint requires, or is
        not linked to a house.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }
    NotFound:
      description: No such resource for this API key's house.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

    ItemIntegrityUnavailable:
      description: An item or its serialized relationships have invalid public identities or duplicate translations. Lot reads can report this when expanding item, images or item.images. Contact support; failed writes are rolled back.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

    ReferenceIdentityUnavailable:
      description: A stored reference record has no valid public UUID. Contact support; reads never invent an ID, omit the row or repair it.
      content:
        application/problem+json:
          schema: { $ref: '#/components/schemas/Problem' }

  schemas:
    Maker:
      type: object
      description: Text uses exactly the house default language; missing translations are null.
      properties:
        id: { type: string, example: "mkr_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        slug: { type: string }
        type: { type: string, enum: [person, company, brand, factory, workshop, collective, unknown] }
        active: { type: boolean }
        short_description: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ItemGalleryWrite:
      type: object
      additionalProperties: false
      required: [images, featured]
      properties:
        images:
          type: array
          maxItems: 500
          uniqueItems: true
          description: Complete ordered public gallery. Omitted images become private. Accepts typed image IDs or bare UUIDs, case-insensitively; duplicates are rejected after normalization.
          items: { type: string }
        featured:
          type: [string, "null"]
          description: First listed image identifier, or null for no featured image. Required even for an empty gallery.

    ItemGallery:
      type: object
      required: [item, images]
      properties:
        item: { type: string, description: "Parent itm_ identifier." }
        images:
          type: array
          items: { $ref: '#/components/schemas/ItemImage' }

    ManagedItemImage:
      type: object
      description: Private/import management metadata; separate from the public gallery ItemImage shape.
      additionalProperties: false
      properties:
        id: { type: string, description: "img_ identity." }
        item: { type: string, description: "itm_ identity of this same-house parent." }
        external_reference: { type: [string, "null"] }
        public: { type: boolean }
        featured: { type: boolean }
        processing: { type: boolean, description: "Existing work-pending flag. False does not prove success." }
        processing_failure:
          type: [object, "null"]
          description: "Latest recorded derivative-processing failure while the existing processing flag is true; automatic retries may still run. Cleared on a successful attempt or source replacement by a new worker/web process; hidden when an older worker completes by clearing the processing flag. During a mixed-version rollout it is only an attempt diagnostic, not proof about the current source. Null is not proof of success. Exception messages and storage URLs are never exposed."
          required: [code, at]
          properties:
            code: { type: string, enum: [derivative_processing_failed] }
            at: { type: string, format: date-time }
        gallery_position: { type: [integer, "null"], minimum: 0, description: "Dense zero-based position in the complete current public gallery; null for private images. Independent of management list order and filters." }
        original_url: { type: [string, "null"], description: "Original upload URL, potentially large; only exposed by this explicit management read. URLs are not existence checks." }
        url: { type: [string, "null"] }
        preview_url: { type: [string, "null"] }
        thumbnail_url: { type: [string, "null"] }
        mini_url: { type: [string, "null"] }
        created_at: { type: [string, "null"], format: date-time }
        updated_at: { type: [string, "null"], format: date-time }

    Consignment:
      type: object
      required: [id, reference, seller, auction, locked, created_at, updated_at]
      properties:
        id: { type: string, example: "con_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        reference: { type: [string, "null"] }
        seller: { type: [string, "null"], description: Active same-house sel_ identity, or null for an unavailable relationship. }
        auction: { type: [string, "null"], description: Optional same-house auc_ identity. }
        locked: { type: boolean, description: New item assignments are disallowed while locked. }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    IncrementStrategy:
      type: object
      description: Existing bid ladder. Each step spans from the previous until (zero initially) exclusively to its own until inclusively. Amounts use Money in minor units. This does not calculate the next bid for a lot.
      required: [id, name, steps, created_at, updated_at]
      properties:
        id: { type: string, example: "inc_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        name: { type: string }
        steps:
          type: array
          items:
            type: object
            required: [until, step_size]
            properties:
              until: { $ref: '#/components/schemas/Money' }
              step_size: { $ref: '#/components/schemas/Money' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    DeliveryClass:
      type: object
      description: Text uses exactly the house default language; missing translations are null.
      properties:
        id: { type: string, example: "dlc_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        position: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Seller:
      type: object
      description: Active seller profile. Bank details, addresses and private comments are not part of this reference read.
      properties:
        id: { type: string, example: "sel_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        seller_number: { type: [integer, "null"] }
        name: { type: [string, "null"], description: "Profile name, inheriting linked account details within the same house when no profile copy exists." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Category:
      type: object
      description: House-owned category. The name uses exactly the house default language; a missing translation is null.
      properties:
        id: { type: string, example: "cat_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        name: { type: [string, "null"] }
        slug: { type: [string, "null"] }
        parent: { type: [string, "null"], description: "Parent cat_ identity within the same house, or null." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    Problem:
      type: object
      description: RFC 9457 Problem Details.
      required: [type, title, status]
      properties:
        errors:
          type: array
          description: Optional bounded public field errors for item input validation.
          items:
            type: object
            required: [field, code, message]
            properties:
              field: { type: string, example: estimate.low.amount }
              code: { type: string, example: invalid }
              message: { type: string, example: Must be whole currency units expressed as minor units. }
        type:   { type: string, format: uri, example: "https://docs.bidvise.com/api/problems/not-found" }
        title:  { type: string, example: "Resource not found" }
        status: { type: integer, example: 404 }
        detail: { type: string }
        instance: { type: string, example: "/api/v2/lots/lot_b48e0a19-5c72-4d83-91af-3e6b2c07d514" }

    Money:
      type: object
      description: An amount in integer minor units, with an ISO 4217 currency.
      required: [amount, currency]
      properties:
        amount:   { type: integer, description: Minor units — 12000 is €120,00., example: 12000 }
        currency: { type: string, description: ISO 4217 code., example: "EUR" }

    MoneyRange:
      type: object
      description: A low/high estimate. Either bound may be absent.
      properties:
        low:  { $ref: '#/components/schemas/Money' }
        high: { $ref: '#/components/schemas/Money' }

    Image:
      type: object
      description: |
        The URLs a client needs to display a photograph. `url` and `preview_url`
        preserve the aspect ratio; `thumbnail_url` and `mini_url` are square
        crops, which can cut off a tall or wide lot. The original upload is not
        exposed — it is unbounded in size. Shared by every image the API
        exposes, whatever it is attached to.
      properties:
        id: { type: string, example: "img_c17a4e52-6b90-4f38-a2d5-0e8b1c4d7936" }
        url:
          type: [string, "null"]
          format: uri
          description: Display size, up to 900x1200. Aspect ratio preserved.
        preview_url:
          type: [string, "null"]
          format: uri
          description: Up to 250x250, aspect ratio preserved — the whole lot, small.
        thumbnail_url:
          type: [string, "null"]
          format: uri
          description: Exactly 250x250, cropped square.
        mini_url:
          type: [string, "null"]
          format: uri
          description: Exactly 70x70, cropped square.

    ItemImage:
      description: |
        A photograph in an item's gallery: a rendition, plus the two things the
        auction house decides about it — where it sits in the gallery, and
        whether it is the featured image.
      allOf:
        - $ref: '#/components/schemas/Image'
        - type: object
          properties:
            position:
              type: integer
              description: |
                Zero-based place in the gallery. Dense and never null, so it
                always equals this image's index in the `images` array.
              example: 0
            featured:
              type: boolean
              description: |
                Whether the house picked this as the item's featured image.
                May be false on every image of an item — do not assume exactly one.
              example: true

    ItemText:
      description: Explicit text fields for one stored locale.
      type: object
      additionalProperties: false
      properties:
        title: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        description: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        condition: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        dimensions: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        weight: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }

    ItemWrite:
      type: object
      additionalProperties: false
      description: |
        Strict JSON: unknown keys, wrong types, NUL bytes and unknown locales are rejected.
        Flat text writes the house default locale. Conflicting flat/default-locale values are rejected.
        Omitted values are preserved. No site IDs, numeric foreign keys or arbitrary model fields.
      properties:
        external_reference: { type: string, minLength: 1, pattern: '^(?![\s\S]*\u0000)(?![\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$)', description: "Nonblank case-sensitive importer reference, at most 1024 UTF-8 bytes, reserved within the house including archived items." }
        title: { type: string, minLength: 1, pattern: '^(?![\s\S]*\u0000)(?![\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$)', description: Nonblank default-language title; cannot be cleared. }
        description: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        condition: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        dimensions: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        weight: { type: [string, "null"], pattern: '^(?![\s\S]*\u0000)' }
        seller:
          type: [string, "null"]
          description: Existing active same-house sel_ ID or bare UUID. Null clears, unless an existing consignment requires its seller.
        consignment:
          type: [string, "null"]
          description: Same-house con_ ID or bare UUID; requires seller_profiles.read. Omission preserves, null clears while preserving seller. An empty seller is inferred unless explicitly supplied; existing sellers must match. New assignments to locked consignments are rejected.
        categories:
          type: array
          uniqueItems: true
          description: Existing same-house cat_ IDs or bare UUIDs. Canonical duplicates are rejected. An empty array clears.
          items: { type: string }
        makers:
          type: array
          uniqueItems: true
          description: Ordered same-house mkr_ IDs or bare UUIDs, including inactive makers. Canonical duplicates are rejected. Empty clears; omission preserves.
          items: { type: string }
        delivery_class:
          type: [string, "null"]
          description: Existing same-house dlc_ ID or bare UUID. Null clears; omission preserves.
        translations:
          type: object
          propertyNames:
            enum: [nl, en, fr, de, es, zh, it, pl, hu]
          additionalProperties: { $ref: '#/components/schemas/ItemText' }
          description: Supplied locale fields only; the house default title cannot be blank or null.
        estimate:
          type: [object, "null"]
          additionalProperties: false
          minProperties: 1
          description: Low must not exceed high after merging with stored bounds. Null clears both; null bound clears that bound.
          properties:
            low: { $ref: '#/components/schemas/ItemEstimateAmount' }
            high: { $ref: '#/components/schemas/ItemEstimateAmount' }

    ItemCreate:
      allOf:
        - $ref: '#/components/schemas/ItemWrite'
        - required: [external_reference]
          anyOf:
            - required: [title]
            - required: [translations]
      description: Supply a nonblank title directly or in translations under the house default locale, plus external_reference.

    ItemEstimateAmount:
      description: An estimate bound in the house currency, or null to clear it.
      type: [object, "null"]
      additionalProperties: false
      required: [amount, currency]
      properties:
        amount:
          type: integer
          minimum: 0
          maximum: 214748364700
          multipleOf: 100
          description: Existing estimates store whole currency units. Send minor units in multiples of 100; fractions are rejected, never truncated.
        currency: { type: string, description: Must match the house currency, example: EUR }

    Item:
      type: object
      description: A physical object in the house's catalogue.
      properties:
        id:          { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
        external_reference:
          type: [string, "null"]
          description: Importer reference, mapped to v1 tracking_identifier. Blank or absent references are null; the itm_ identifier remains the stable resource identity.
          example: "2026_9-1000"
        title:       { type: [string, "null"] }
        description: { type: [string, "null"] }
        condition:   { type: [string, "null"] }
        dimensions:  { type: [string, "null"] }
        weight:      { type: [string, "null"] }
        seller: { type: [string, "null"], description: Active same-house seller ID; no private seller fields., example: "sel_9f6a87c1-837e-4cd1-9cf8-d3bc8bd4ea20" }
        consignment: { type: [string, "null"], description: Active same-house con_ identity; no consignment terms. }
        categories:
          type: array
          description: Same-house category IDs, sorted by public ID.
          items: { type: string, example: "cat_83c7f3dc-0e1a-4557-b4f4-1ed738af6f10" }
        makers:
          type: array
          description: Same-house maker IDs in the item's configured order. Empty when none are assigned.
          items: { type: string }
        delivery_class:
          type: [string, "null"]
          description: Same-house delivery-class ID, or null when unassigned.
        translations:
          type: object
          description: Stored locale rows only. Missing translations are not fabricated from fallbacks.
          additionalProperties: { $ref: '#/components/schemas/ItemText' }
        estimate:    { $ref: '#/components/schemas/MoneyRange' }
        images:
          description: |
            Image ids, or full objects when `expand[]=images`. Always in
            gallery order, so the first entry is the image shown first.
          oneOf:
            - type: array
              items: { type: string, example: "img_c17a4e52-6b90-4f38-a2d5-0e8b1c4d7936" }
            - type: array
              items: { $ref: '#/components/schemas/ItemImage' }
        created_at:  { type: string, format: date-time }
        updated_at:  { type: string, format: date-time }

    Lot:
      type: object
      description: An item offered in a session, numbered for the sale.
      properties:
        id:           { type: string, example: "lot_b48e0a19-5c72-4d83-91af-3e6b2c07d514" }
        lot_number:   { type: [integer, "null"] }
        active:
          type: boolean
          description: Whether the lot is marked active; legacy unset values are false. Matches the active list filter. Session/auction publication and customer bidding eligibility are separate.
        title:        { type: [string, "null"] }
        starting_bid: { $ref: '#/components/schemas/Money' }
        current_bid:  { $ref: '#/components/schemas/Money' }
        sold:         { type: boolean }
        closes_at:
          type: [string, "null"]
          format: date-time
          description: |
            When this lot actually stops taking bids. In a timed sale a bid
            within the session's `timed.extension_threshold_seconds` of the close
            moves it later by `timed.extension_seconds`; in a live sale it is
            when the session's online bidding closes, and never moves.
        timed:
          description: |
            What only a timed lot has, or null in a live sale.
          type: [object, "null"]
          properties:
            scheduled_closes_at:
              type: [string, "null"]
              format: date-time
              description: |
                When it was scheduled to close, before any extension. Differs
                from closes_at once a late bid has moved it.
        item:
          description: An item id, or the full object when `expand[]=item`.
          oneOf:
            - { type: string, example: "itm_2d9f6b31-8e4a-4c05-b7d2-9a1e3f8c6047" }
            - { $ref: '#/components/schemas/Item' }
        session:
          description: A session id, or the full object when `expand[]=session`.
          oneOf:
            - { type: string, example: "ses_7c1d5e88-2a34-4f19-9b0c-6d2e8f4a1357" }
            - { $ref: '#/components/schemas/Session' }
        created_at:   { type: string, format: date-time }
        updated_at:   { type: string, format: date-time }

    SessionWriteSettings:
      type: object
      additionalProperties: false
      description: Supplied values override creation defaults or current empty-draft settings. Omitted fields remain unchanged. This does not enable customer bidding or publish the session.
      properties:
        buyer_premium_percentage:
          description: Percentage from 0 to 100, at most two decimal places. Decimal strings use a dot. No rounding or null clearing.
          oneOf:
            - { type: number, minimum: 0, maximum: 100, multipleOf: 0.01 }
            - { type: string, pattern: '^(?:[0-9]{1,2}(?:\.[0-9]{1,2})?|100(?:\.0{1,2})?)$' }
        allow_proxy_bidding: { type: boolean }
        show_lot_numbers: { type: boolean }
        live_written_bids_compete: { type: boolean, description: Whether written bids compete during a live session. Timed sessions always let them compete. }

    SessionTimedWrite:
      type: object
      additionalProperties: false
      required: [first_lot_closes_at, group_size, interval_seconds]
      properties:
        first_lot_closes_at: { type: string, format: date-time, description: Explicit offset; must be after opens_at. }
        group_size: { type: integer, minimum: 1, maximum: 2147483647 }
        interval_seconds: { type: integer, minimum: 1, maximum: 2147483647 }
        extension_threshold_seconds: { type: integer, minimum: 0, maximum: 2147483647 }
        extension_seconds: { type: integer, minimum: 0, maximum: 2147483647 }

    SessionLiveWrite:
      type: object
      additionalProperties: false
      required: [online_bidding_closes_at, live_bidding_starts_at]
      properties:
        online_bidding_closes_at: { type: string, format: date-time, description: Explicit offset; after opens_at and no later than live_bidding_starts_at. }
        live_bidding_starts_at: { type: string, format: date-time, description: Explicit offset; must be after opens_at. }

    SessionCreate:
      type: object
      additionalProperties: false
      required: [title, external_reference, auction, kind, opens_at]
      properties:
        title: { type: string, minLength: 1, maxLength: 255, pattern: '^(?![\s\S]*\u0000)(?![\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$)', description: Non-blank session title. }
        external_reference: { type: string, minLength: 1, maxLength: 255, pattern: '^(?![\s\S]*\u0000)(?![\u0009-\u000D\u0020\u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]*$)', description: Non-blank case-sensitive import reference unique across the house's sessions. }
        auction: { type: string, description: An auc_ identifier or auction UUID belonging to this house. }
        increment_strategy: { type: string, description: Optional inc_ identifier or UUID belonging to this house. }
        settings: { $ref: '#/components/schemas/SessionWriteSettings' }
        kind: { type: string, enum: [timed, live] }
        opens_at: { type: string, format: date-time, description: ISO 8601 timestamp with explicit offset. }
        timed: { $ref: '#/components/schemas/SessionTimedWrite' }
        live: { $ref: '#/components/schemas/SessionLiveWrite' }
      oneOf:
        - properties:
            kind: { const: timed }
          required: [timed]
          not: { required: [live] }
        - properties:
            kind: { const: live }
          required: [live]
          not: { required: [timed] }

    Session:
      type: object
      description: A sitting within an auction, with its own opening and closing.
      properties:
        external_reference:
          type: [string, "null"]
          description: Stable case-sensitive import reference within this house; null for existing sessions without one.
        increment_strategy:
          type: [string, "null"]
          description: The stored house-owned increment strategy identifier, or null when unavailable in this house. A malformed stored UUID produces Resource identity unavailable.
        id:        { type: string, example: "ses_7c1d5e88-2a34-4f19-9b0c-6d2e8f4a1357" }
        title:     { type: [string, "null"] }
        kind:      { type: [string, "null"], enum: [timed, live, null] }
        opens_at:  { type: [string, "null"], format: date-time }
        timed:
          description: |
            The timed schedule, or null for a live session. A field that does
            not apply to this kind is absent rather than borrowed.
          type: [object, "null"]
          properties:
            first_lot_closes_at: { type: [string, "null"], format: date-time }
            group_size:          { type: [integer, "null"], description: How many lots close together. }
            interval_seconds:    { type: [integer, "null"], description: Seconds between one group closing and the next. }
            extension_threshold_seconds:
              type: integer
              description: A bid this many seconds before a lot's close extends it. 120 unless the house set otherwise.
            extension_seconds:
              type: integer
              description: How many seconds a late bid adds. 120 unless the house set otherwise.
        live:
          description: The live schedule, or null for a timed session.
          type: [object, "null"]
          properties:
            online_bidding_closes_at: { type: [string, "null"], format: date-time }
            live_bidding_starts_at:   { type: [string, "null"], format: date-time }
        settings:
          type: object
          description: Stored settings. Accepted on POST and on PATCH for empty unpublished unfinished drafts.
          properties:
            buyer_premium_percentage: { type: [string, "null"], description: Exact decimal percentage as a string. Null means the legacy row has no explicit premium (effective zero). Existing values are reported without normalization; writes validate 0–100 and two decimal places. }
            allow_proxy_bidding: { type: boolean }
            show_lot_numbers: { type: boolean }
            live_written_bids_compete: { type: boolean, description: Stored live-session option; timed sessions always let written bids compete. }
        published: { type: boolean }
        finished:  { type: boolean }
        auction:
          description: An auction id, or the full object when `expand[]=auction`.
          oneOf:
            - { type: string, example: "auc_3f2a1c04-9b7e-4d51-8a63-1e5c7d90b482" }
            - { $ref: '#/components/schemas/Auction' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Auction:
      type: object
      description: The event a house announces, containing one or more sessions.
      properties:
        external_reference:
          type: [string, "null"]
          description: Stable case-sensitive import reference within this house; null for existing auctions without one.
        id:          { type: string, example: "auc_3f2a1c04-9b7e-4d51-8a63-1e5c7d90b482" }
        title:       { type: [string, "null"] }
        description: { type: [string, "null"] }
        slug:        { type: [string, "null"] }
        published:   { type: boolean }
        opens_at:    { type: [string, "null"], format: date-time, description: Earliest session opening. }
        sessions:
          description: Session ids, or full objects when `expand[]=sessions`.
          oneOf:
            - type: array
              items: { type: string, example: "ses_7c1d5e88-2a34-4f19-9b0c-6d2e8f4a1357" }
            - type: array
              items: { $ref: '#/components/schemas/Session' }
        created_at:  { type: string, format: date-time }
        updated_at:  { type: string, format: date-time }
