openapi: 3.1.0
info:
  title: Nucleo Catalog API
  version: v1
  summary: Load products into a Nucleo catalog, read them back, and get told when they change.
  description: |
    The Catalog API lets the software of another company — an ERP, a PLM, a supplier portal, a marketplace
    connector — keep products in sync with **one Nucleo catalog**:

    - **Load** new products and update existing ones, matched by SKU or by your own id (`external_id`),
      with variants, prices, stock, attributes, categories, translations and images.
    - **Load in bulk**: up to 100 products per call with a result per product, or up to 5,000 in a queued import.
    - **Download** the whole catalog page by page, then only what changed since your last read (`updated_since`).
    - **Get notified** by signed webhooks when a product is created, changed or deleted in Nucleo, or when stock changes.
    - **Read** the structures a product refers to: categories, attributes and their allowed values, locales,
      markets, price lists and locations.

    **Authentication.** A key per catalog, sent as `Authorization: Bearer nck_…`. An owner or admin of the
    catalog creates it in Nucleo; the key is shown once. A key is either *read only* or *read and write*, and
    it only ever sees its own catalog: there is no header to pick a company or a catalog.

    **Writes go through the same import as every other source.** Products are matched by SKU (upsert: the
    same payload sent twice does not create duplicates). Each key has one **field rule**, chosen in Nucleo
    when the key is created: `sorgente` (the API always updates the fields it sends), `inserimento` (the API
    only fills fields that are empty in Nucleo) or `nucleo` (the API does not change existing fields). Fields
    that another connected system owns are never overwritten: the call succeeds and the result says what
    was left out. Every write appears in the product's **Activity** in Nucleo, with the API and the key's
    name as the author. Writing through the API never publishes anything to Shopify or other sales channels.

    **Conventions.** JSON in and out, UTF-8. Dates are ISO 8601 in UTC (`2026-10-14T08:00:00Z`). Amounts are
    strings with two decimals (`"19.90"`); on input, numbers or numeric strings. Errors always have the same
    shape (`{"error": {"code", "message", "request_id", "details"}}`), in English. Every response carries
    `X-Request-Id` and `Nucleo-Api-Version: v1`. Fields not sent are left as they are: an absent or empty
    value never clears a field.
x-nucleo:
  product: catalog
  module: catalog
  audience: [partner]
  stability: beta
  format: json
  order: 1
servers:
  - url: https://api-catalog.nucleoplatform.com/api/catalog/v1
    description: Production. There is no sandbox.
security:
  - catalogKey: []
tags:
  - name: Key
    description: Check that a key works, and for which catalog.
  - name: Products
    description: |
      Read, create, update and delete products. A product is addressed by its Nucleo `id` or by its `sku`
      (`/products/123` or `/products/TEE-001`).
  - name: Bulk
    description: |
      Many products in one call. `POST /products/batch` (up to 100) answers with the result of each product;
      `POST /imports` (up to 5,000) is queued and its progress is read with `GET /imports/{id}`. Both go
      through the same checks: an invalid product is reported and skipped, the others are written.
  - name: Variants
    description: Variants of a product, addressed by their `id` or `sku`.
  - name: Prices and stock
    description: |
      The two most frequent updates, per product or in bulk by variant SKU (up to 1,000 lines). Only the
      price lists and locations you send change.
  - name: Product parts
    description: Attributes, one translation, categories and images of a product.
  - name: Structures
    description: |
      The codes a product can use. They are managed in Nucleo and read-only here; an unknown code in a
      write is an error that names it.
  - name: Webhooks
    description: |
      Up to 10 HTTPS endpoints per key, each with the events it wants and its own signing secret. Webhooks
      can be managed with a read-only key too: receiving news does not change the catalog.

paths:
  /ping:
    get:
      operationId: ping
      summary: Check the key
      tags: [Key]
      description: Which company, catalog and key the request is authenticated as, and the server time.
      x-rate-limit: &rl
        limit: 600
        window: 1m
        scope: per key (default; the catalog owner can set 1–6,000)
      responses:
        '200':
          description: The key works.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: '#/components/schemas/Ping'
              example:
                data:
                  api_version: v1
                  company: { id: 12 }
                  catalog: { id: 4, name: Acme }
                  key: { id: 9b2f1c6e-3a4d-4f5e-8a7b-1c2d3e4f5a6b, name: ERP sync, level: write, field_rule: sorgente, rate_limit_per_minute: 600 }
                  webhooks: 1
                  server_time: '2026-10-14T08:00:00Z'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'

  /products:
    get:
      operationId: listProducts
      summary: List products
      tags: [Products]
      x-rate-limit: *rl
      description: |
        Products ordered by `id`, page by page. With `updated_since`, only the products that changed at or
        after that moment, ordered by when they changed: use the `server_time` of your previous sync
        (from `GET /ping`) or the latest `updated_at` you stored.

        **What counts as a change**: anything recorded in the product's Activity in Nucleo (made in the app,
        by an import, by another connected system, by Atomo or by this API) and every change of a stock
        quantity made on the product page, by an import or by this API.

        **Deleted products.** With `updated_since`, a product moved to Nucleo's trash comes back as
        `{"id", "sku", "deleted": true, "updated_at"}`: remove it from your copy. A product stays in the
        trash for 30 days (and can be restored, which counts as a change), then it is deleted for good and
        no longer listed. Without `updated_since`, products in the trash are not listed.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: updated_since
          in: query
          description: ISO 8601 date-time. Only products changed at or after this moment, deleted ones included.
          schema: { type: string, format: date-time }
          example: '2026-10-13T08:00:00Z'
        - name: sku
          in: query
          description: Only these SKUs, comma separated (1–100).
          schema: { type: string }
          example: TEE-001,TEE-002
        - name: status
          in: query
          description: Only products with this status code (for example `active`, `draft`).
          schema: { type: string }
      responses:
        '200':
          description: One page of products.
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items:
                      oneOf:
                        - $ref: '#/components/schemas/Product'
                        - $ref: '#/components/schemas/DeletedProduct'
                  next_cursor:
                    type: [string, 'null']
                    description: Pass it as `cursor` to get the next page; `null` on the last page.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      operationId: upsertProduct
      summary: Create or update a product
      tags: [Products]
      x-rate-limit: *rl
      description: |
        Creates the product, or updates it if the SKU already exists (also if it is known by an earlier SKU).
        Without `sku`, an `external_id` this key already sent finds the product.

        Only the fields you send are written; variants, prices and stock are added or updated, never removed
        (delete a variant with `DELETE /products/{product}/variants/{variant}`).

        Answers `201` when the product was created and `200` when it existed. `result.status` says what
        happened; `result.notes` explains fields that were not written (the key's field rule, or a field
        owned by another system). A product that could not be written at all is a `400 product_not_written`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/ProductInput'
                - required: [sku]
            example:
              sku: TEE-001
              external_id: erp-778
              title: Basic tee
              description: <p>Organic cotton.</p>
              status: active
              brand: Acme
              product_type: T-shirt
              category: Clothing > Tops
              attributes: { color: red, fit: regular }
              translations:
                it: { title: Maglietta basic, description: '<p>Cotone biologico.</p>' }
              image_urls: ['https://cdn.acme.example/tee-001-front.jpg']
              variants:
                - sku: TEE-001-M
                  barcode: '8001234567890'
                  size: M
                  weight: 0.2
                  weight_unit: kg
                  prices: [{ price_list: eu, price: 19.9, sale_price: 15 }]
                  stock: [{ location: main, available: 7 }]
      responses:
        '201':
          $ref: '#/components/responses/ProductWritten'
        '200':
          $ref: '#/components/responses/ProductWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ReadOnly'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'

  /products/{product}:
    parameters:
      - $ref: '#/components/parameters/Product'
    get:
      operationId: getProduct
      summary: Get a product
      tags: [Products]
      x-rate-limit: *rl
      responses:
        '200':
          description: The product.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Product' }
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateProduct
      summary: Update a product
      tags: [Products]
      x-rate-limit: *rl
      description: |
        Writes only the fields you send; everything else stays as it is. The SKU cannot change through the
        API (sending a different `sku` is a `400`).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ProductInput' }
            example: { title: Basic tee — new season, status: active }
      responses:
        '200':
          $ref: '#/components/responses/ProductWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/ReadOnly'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      operationId: deleteProduct
      summary: Delete a product
      tags: [Products]
      x-rate-limit: *rl
      description: |
        Moves the product to Nucleo's trash, from where it can be restored for 30 days. Its SKU becomes free
        for a new product. Not allowed with the field rules `inserimento` and `nucleo` (`409 field_rule`).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: Moved to the trash.
        '403':
          $ref: '#/components/responses/ReadOnly'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /products/batch:
    post:
      operationId: batchUpsertProducts
      summary: Create or update up to 100 products
      tags: [Bulk]
      x-rate-limit: *rl
      description: |
        Each product is checked and written on its own, as with `POST /products`: an invalid product comes
        back as `invalid` with its errors and the others are written. A SKU repeated in the same request is
        invalid (it would not be clear which version wins). The answer lists the products in the order sent.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [products]
              properties:
                products:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { $ref: '#/components/schemas/ProductInput' }
            example:
              products:
                - { sku: TEE-001, title: Basic tee, variants: [{ sku: TEE-001-M, prices: [{ price_list: eu, price: 19.9 }] }] }
                - { sku: TEE-002, title: Pocket tee }
      responses:
        '200':
          description: The result of each product.
          content:
            application/json:
              schema:
                type: object
                required: [data, summary]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ItemResult' }
                  summary: { $ref: '#/components/schemas/Summary' }
              example:
                data:
                  - { index: 0, sku: TEE-001, product_id: 1201, status: created, errors: [], notes: [] }
                  - { index: 1, sku: TEE-002, product_id: 1202, status: updated, errors: [], notes: [] }
                summary: { created: 1, updated: 1, partial: 0, skipped: 0, failed: 0, invalid: 0 }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/ReadOnly'

  /imports:
    post:
      operationId: createImport
      summary: Queue an import of up to 5,000 products
      tags: [Bulk]
      x-rate-limit: *rl
      description: |
        The products are checked at once (invalid ones are reported in the results and not written), then
        written in the background in groups of 100. Follow the progress with `GET /imports/{id}`. Usually it
        starts within a minute.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [products]
              properties:
                products:
                  type: array
                  minItems: 1
                  maxItems: 5000
                  items: { $ref: '#/components/schemas/ProductInput' }
      responses:
        '202':
          description: Accepted and queued.
          headers:
            Location:
              description: Where to follow the import.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Import' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/ReadOnly'
        '413':
          $ref: '#/components/responses/TooLarge'

  /imports/{import}:
    get:
      operationId: getImport
      summary: Follow an import
      tags: [Bulk]
      x-rate-limit: *rl
      description: Progress and, product by product, the results so far. Only imports created with the same key.
      parameters:
        - name: import
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: The import.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Import'
                      - type: object
                        properties:
                          results:
                            type: array
                            items: { $ref: '#/components/schemas/ItemResult' }
                          summary: { $ref: '#/components/schemas/Summary' }
        '404':
          $ref: '#/components/responses/NotFound'

  /products/{product}/variants:
    parameters:
      - $ref: '#/components/parameters/Product'
    get:
      operationId: listVariants
      summary: List the variants of a product
      tags: [Variants]
      x-rate-limit: *rl
      responses:
        '200':
          description: The variants.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Variant' }
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      operationId: upsertVariant
      summary: Add or update a variant
      tags: [Variants]
      x-rate-limit: *rl
      description: Creates the variant (`201`) or updates the variant of this product with the same SKU (`200`).
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VariantInput' }
      responses:
        '201':
          $ref: '#/components/responses/VariantWritten'
        '200':
          $ref: '#/components/responses/VariantWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/ReadOnly'
        '404':
          $ref: '#/components/responses/NotFound'

  /products/{product}/variants/{variant}:
    parameters:
      - $ref: '#/components/parameters/Product'
      - name: variant
        in: path
        required: true
        description: Variant id or SKU.
        schema: { type: string }
    patch:
      operationId: updateVariant
      summary: Update a variant
      tags: [Variants]
      x-rate-limit: *rl
      description: Only the fields sent change. The SKU cannot change through the API.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VariantInput'
      responses:
        '200':
          $ref: '#/components/responses/VariantWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: deleteVariant
      summary: Delete a variant
      tags: [Variants]
      x-rate-limit: *rl
      description: Deletes the variant with its prices and stock. Not allowed with the field rules `inserimento` and `nucleo`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: Deleted.
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /products/{product}/prices:
    parameters:
      - $ref: '#/components/parameters/Product'
    put:
      operationId: setProductPrices
      summary: Set prices of a product
      tags: [Prices and stock]
      x-rate-limit: *rl
      description: One line per variant and price list (up to 500). Price lists not sent do not change.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prices]
              properties:
                prices:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    allOf:
                      - type: object
                        required: [variant_sku]
                        properties:
                          variant_sku: { type: string, description: 'SKU of a variant of this product.' }
                      - $ref: '#/components/schemas/PriceInput'
            example:
              prices:
                - { variant_sku: TEE-001-M, price_list: eu, price: 19.9, sale_price: 15 }
                - { variant_sku: TEE-001-L, price_list: eu, price: 19.9 }
      responses:
        '200':
          $ref: '#/components/responses/VariantsWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /products/{product}/stock:
    parameters:
      - $ref: '#/components/parameters/Product'
    put:
      operationId: setProductStock
      summary: Set stock of a product
      tags: [Prices and stock]
      x-rate-limit: *rl
      description: |
        One line per variant and location (up to 500): `available` replaces the quantity (and the on-hand
        quantity) at that location. Locations not sent do not change.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stock]
              properties:
                stock:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    allOf:
                      - type: object
                        required: [variant_sku]
                        properties:
                          variant_sku: { type: string }
                      - $ref: '#/components/schemas/StockInput'
            example:
              stock: [{ variant_sku: TEE-001-M, location: main, available: 12 }]
      responses:
        '200':
          $ref: '#/components/responses/VariantsWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /prices:
    put:
      operationId: bulkSetPrices
      summary: Set prices in bulk
      tags: [Prices and stock]
      x-rate-limit: *rl
      description: Up to 1,000 lines, each for one variant SKU of any product. The answer has one result per line, in order.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [prices]
              properties:
                prices:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    allOf:
                      - type: object
                        required: [sku]
                        properties:
                          sku: { type: string, description: 'Variant SKU.' }
                      - $ref: '#/components/schemas/PriceInput'
            example:
              prices:
                - { sku: TEE-001-M, price_list: eu, price: 21, sale_price: null }
                - { sku: TEE-002-S, price_list: us, price: 25 }
      responses:
        '200':
          $ref: '#/components/responses/LinesWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/ReadOnly'

  /stock:
    put:
      operationId: bulkSetStock
      summary: Set stock in bulk
      tags: [Prices and stock]
      x-rate-limit: *rl
      description: Up to 1,000 lines, each for one variant SKU and one location. The answer has one result per line, in order.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [stock]
              properties:
                stock:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items:
                    allOf:
                      - type: object
                        required: [sku]
                        properties:
                          sku: { type: string, description: 'Variant SKU.' }
                      - $ref: '#/components/schemas/StockInput'
            example:
              stock:
                - { sku: TEE-001-M, location: main, available: 12 }
                - { sku: TEE-001-L, location: main, available: 0 }
      responses:
        '200':
          $ref: '#/components/responses/LinesWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/ReadOnly'

  /products/{product}/attributes:
    parameters:
      - $ref: '#/components/parameters/Product'
    put:
      operationId: setProductAttributes
      summary: Set attribute values
      tags: [Product parts]
      x-rate-limit: *rl
      description: |
        The values sent, by attribute code (`GET /attributes`); other attributes keep their value. For an
        attribute with options, send the option code or its label: a value the attribute does not have yet
        becomes a new option.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attributes]
              properties:
                attributes:
                  type: object
                  additionalProperties: true
                attribute_set:
                  type: string
                  description: Name or id of the attribute set to assign.
            example:
              attributes: { color: red, fit: regular }
      responses:
        '200':
          $ref: '#/components/responses/ProductWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /products/{product}/translations/{locale}:
    parameters:
      - $ref: '#/components/parameters/Product'
      - name: locale
        in: path
        required: true
        description: A locale code of the catalog (`GET /locales`). The default locale writes the product's own title and description.
        schema: { type: string }
        example: it
    put:
      operationId: setProductTranslation
      summary: Set one translation
      tags: [Product parts]
      x-rate-limit: *rl
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, maxLength: 255 }
                description: { type: string, description: 'HTML, up to 65,000 characters.' }
            example: { title: Maglietta basic, description: '<p>Cotone biologico.</p>' }
      responses:
        '200':
          $ref: '#/components/responses/ProductWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /products/{product}/categories:
    parameters:
      - $ref: '#/components/parameters/Product'
    put:
      operationId: setProductCategories
      summary: Set the categories
      tags: [Product parts]
      x-rate-limit: *rl
      description: |
        The full set of categories of the product, by id (`GET /categories`): categories not in the list are
        removed, and an empty list removes them all. With the field rule `inserimento` only a product without
        categories can be changed; with `nucleo`, none.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [category_ids]
              properties:
                category_ids:
                  type: array
                  maxItems: 200
                  items: { type: integer }
            example: { category_ids: [12, 31] }
      responses:
        '200':
          description: The categories of the product now.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ProductCategory' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /products/{product}/images:
    parameters:
      - $ref: '#/components/parameters/Product'
    post:
      operationId: addProductImage
      summary: Add an image
      tags: [Product parts]
      x-rate-limit: *rl
      description: |
        Either JSON with the `url` of the image, or `multipart/form-data` with the file in `image`.

        - **From a URL**, Nucleo downloads the image and stores it: no link to your server remains. Only
          public `http(s)` addresses; JPEG, PNG, WebP, GIF, AVIF, HEIC, TIFF or BMP up to 40 MB; never SVG.
          The same URL sent again for the same product returns the image already there (`200`).
        - **As a file**: JPEG, PNG or WebP up to 10 MB.

        `position` starts at 1; without it the image goes last. With the field rule `inserimento` only a
        product without images can get one; with `nucleo`, none.

        Image URLs sent in `image_urls` of a product are different: they are linked as they are, not downloaded.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, maxLength: 2000 }
                alt: { type: string, maxLength: 255 }
                position: { type: integer, minimum: 1 }
            example: { url: 'https://cdn.acme.example/tee-001-back.jpg', alt: Back view, position: 2 }
          multipart/form-data:
            schema:
              type: object
              required: [image]
              properties:
                image: { type: string, format: binary }
                alt: { type: string, maxLength: 255 }
                position: { type: integer, minimum: 1 }
      responses:
        '201':
          $ref: '#/components/responses/ImageWritten'
        '200':
          $ref: '#/components/responses/ImageWritten'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /products/{product}/images/{image}:
    parameters:
      - $ref: '#/components/parameters/Product'
      - name: image
        in: path
        required: true
        schema: { type: integer }
    patch:
      operationId: updateProductImage
      summary: Change alt text or position
      tags: [Product parts]
      x-rate-limit: *rl
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                alt: { type: [string, 'null'], maxLength: 255 }
                position: { type: integer, minimum: 1, description: 'New position; the other images move.' }
      responses:
        '200':
          $ref: '#/components/responses/ImageWritten'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
    delete:
      operationId: deleteProductImage
      summary: Delete an image
      tags: [Product parts]
      x-rate-limit: *rl
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204':
          description: Deleted.
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'

  /categories:
    get:
      operationId: listCategories
      summary: List categories
      tags: [Structures]
      x-rate-limit: *rl
      responses:
        '200':
          description: All categories, flat, with their path.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, parent_id, name, slug, path, status, position]
                      properties:
                        id: { type: integer }
                        parent_id: { type: [integer, 'null'] }
                        name: { type: string }
                        slug: { type: string }
                        path: { type: string, example: Clothing > Tops }
                        status: { type: string }
                        position: { type: integer }

  /attributes:
    get:
      operationId: listAttributes
      summary: List attributes and their allowed values
      tags: [Structures]
      x-rate-limit: *rl
      responses:
        '200':
          description: The product attributes of the catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [code, name, type, scope, required, variant_option, options]
                      properties:
                        code: { type: string, example: color }
                        name: { type: string }
                        type: { type: string, description: 'For example `text`, `number`, `boolean`, `date`, `dropdown`, `multiselect`.' }
                        scope: { type: string, description: '`product` or `variant`.' }
                        required: { type: boolean }
                        variant_option: { type: boolean, description: 'The attribute tells variants apart (size, color…).' }
                        options:
                          type: array
                          description: Allowed values of attributes with options (active ones), with their labels by locale.
                          items:
                            type: object
                            properties:
                              code: { type: string, example: red }
                              labels: { type: object, additionalProperties: { type: string }, example: { en: Red, it: Rosso } }

  /locales:
    get:
      operationId: listLocales
      summary: List locales
      tags: [Structures]
      x-rate-limit: *rl
      responses:
        '200':
          description: The languages of the catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, example: it }
                        name: { type: string, example: Italiano }
                        default: { type: boolean }

  /markets:
    get:
      operationId: listMarkets
      summary: List markets
      tags: [Structures]
      x-rate-limit: *rl
      responses:
        '200':
          description: The markets of the catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                        currency: { type: [string, 'null'] }
                        default_locale: { type: [string, 'null'] }
                        additional_locales: { type: array, items: { type: string } }
                        countries: { type: array, items: { type: string }, description: 'ISO 3166-1 alpha-2.' }
                        price_lists: { type: array, items: { type: string } }
                        active: { type: boolean }
                        default: { type: boolean }

  /price-lists:
    get:
      operationId: listPriceLists
      summary: List price lists
      tags: [Structures]
      x-rate-limit: *rl
      responses:
        '200':
          description: The price lists of the catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, example: eu }
                        name: { type: string }
                        currency: { type: [string, 'null'], example: EUR }
                        active: { type: boolean }
                        default: { type: boolean }

  /locations:
    get:
      operationId: listLocations
      summary: List locations
      tags: [Structures]
      x-rate-limit: *rl
      responses:
        '200':
          description: The stock locations of the catalog.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, example: main }
                        name: { type: string }
                        active: { type: boolean }
                        default: { type: boolean }

  /webhooks:
    get:
      operationId: listWebhooks
      summary: List webhooks
      tags: [Webhooks]
      x-rate-limit: *rl
      responses:
        '200':
          description: The webhooks of this key (without secrets).
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Webhook' }
    post:
      operationId: createWebhook
      summary: Create a webhook
      tags: [Webhooks]
      x-rate-limit: *rl
      description: |
        The URL must be `https` and resolve to a public address. The answer contains the signing `secret`
        (`whsec_…`) — the only time it is shown.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri, maxLength: 500 }
                events:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/EventType' }
            example: { url: 'https://erp.acme.example/nucleo/webhooks', events: [product.created, product.updated, product.deleted, stock.updated] }
      responses:
        '201':
          description: Created. Store the secret now.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Webhook'
                      - type: object
                        required: [secret]
                        properties:
                          secret: { type: string, example: whsec_3KQ9…, description: 'Shown only now.' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'

  /webhooks/{webhook}:
    parameters:
      - name: webhook
        in: path
        required: true
        schema: { type: string, format: uuid }
    get:
      operationId: getWebhook
      summary: Get a webhook
      tags: [Webhooks]
      x-rate-limit: *rl
      responses:
        '200':
          description: The webhook, with the outcome of its last deliveries.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Webhook' }
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateWebhook
      summary: Update a webhook or rotate its secret
      tags: [Webhooks]
      x-rate-limit: *rl
      description: 'With `rotate_secret: true` the answer contains the new `secret`; the old one stops working at once.'
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri, maxLength: 500 }
                events: { type: array, minItems: 1, items: { $ref: '#/components/schemas/EventType' } }
                active: { type: boolean, description: 'An inactive webhook receives nothing; events of that time are not sent later.' }
                rotate_secret: { type: boolean }
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/Webhook'
                      - type: object
                        properties:
                          secret: { type: string, description: 'Only after `rotate_secret`.' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: deleteWebhook
      summary: Delete a webhook
      tags: [Webhooks]
      x-rate-limit: *rl
      responses:
        '204':
          description: Deleted. Pending deliveries are dropped.
        '404':
          $ref: '#/components/responses/NotFound'

webhooks:
  catalogEvent:
    post:
      operationId: catalogEvent
      summary: Something changed in the catalog
      tags: [Webhooks]
      security: []
      description: |
        Sent to every active webhook that subscribed to the event. The notice is small: it says what changed
        and you read the product with `GET /products/{id}` if you need it.

        - `product.created` — a product was created, or restored from the trash.
        - `product.updated` — a product changed; `changed_fields` names the fields (for example `title`,
          `description`, `price:eu`, `attr:color`, `images`, `stock` for the in/out of stock state).
        - `product.deleted` — a product was moved to the trash.
        - `stock.updated` — the available quantity of a variant at a location changed (on the product page,
          by an import or through this API).

        Changes made by any key, including yours, are sent: `triggered_by_this_key` is `true` when the
        change came from the key that owns the webhook, so you can skip your own echoes.

        **Signature.** `X-Nucleo-Signature: t=<unix time>,v1=<signature>`, where the signature is the lowercase
        hex HMAC-SHA256 of `<t>.<raw body>` keyed with the webhook's secret. Verify it on the exact bytes
        received, compare in constant time and reject a `t` older than 5 minutes.

        **Delivery.** 10-second timeout, redirects not followed; any `2xx` is a success. Deliveries leave
        within about a minute of the change. Any other answer is retried about 30 s, 1, 2, 4, 8, 16 and 32
        minutes later (8 attempts in all), then the event is dropped. Every attempt carries the same `id`
        (also in `X-Nucleo-Webhook-Id`) and its number in `X-Nucleo-Attempt`. Events can arrive out of order:
        compare `created_at`, or re-read the product.
      parameters:
        - name: X-Nucleo-Signature
          in: header
          required: true
          schema: { type: string, example: 't=1791115200,v1=5f2b6c0e9a8d4f7b1c3e2a6d9b0f8e7c6a5d4b3c2e1f0a9b8c7d6e5f4a3b2c1d' }
        - name: X-Nucleo-Event
          in: header
          required: true
          schema: { $ref: '#/components/schemas/EventType' }
        - name: X-Nucleo-Webhook-Id
          in: header
          required: true
          schema: { type: string, format: uuid }
        - name: X-Nucleo-Attempt
          in: header
          required: true
          schema: { type: integer, minimum: 1, maximum: 8 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Event' }
            examples:
              updated:
                summary: product.updated
                value:
                  id: 5d1c9a0e-2b7f-4c3a-9e8d-6f1a2b3c4d5e
                  type: product.updated
                  created_at: '2026-10-14T08:00:00Z'
                  triggered_by_this_key: false
                  data: { product_id: 1201, sku: TEE-001, changed_fields: [title, 'price:eu'] }
              stock:
                summary: stock.updated
                value:
                  id: 0a9b8c7d-6e5f-4a3b-2c1d-0e9f8a7b6c5d
                  type: stock.updated
                  created_at: '2026-10-14T08:00:05Z'
                  triggered_by_this_key: true
                  data: { product_id: 1201, sku: TEE-001, variant_id: 3304, variant_sku: TEE-001-M, location: main, available: 11 }
      responses:
        '200':
          description: Received. Any `2xx` works; the body is ignored.

components:
  securitySchemes:
    catalogKey:
      type: http
      scheme: bearer
      bearerFormat: nck_…
      description: |
        A Catalog API key (`nck_` followed by 48 characters). An owner or admin of the catalog creates it in
        Nucleo, chooses whether it is read only or read and write and its field rule, and sees it once.
        Revoking it in Nucleo stops it at once. Thirty failed attempts from the same address within five
        minutes block that address for five minutes (`429`).

  parameters:
    Product:
      name: product
      in: path
      required: true
      description: Product id or SKU.
      schema: { type: string }
      example: TEE-001
    Limit:
      name: limit
      in: query
      description: Items per page, 1–200.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: The `next_cursor` of the previous page. A cursor works only with the same filters it was made with.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Any string of 1–255 printable ASCII characters, unique per change you mean (a UUID is fine). Sending
        the same request again with the same key within 48 hours returns the first answer without doing the
        work again (header `Idempotent-Replayed: true`). The same key with a different request is a `409
        idempotency_key_reused`; while the first request is still running, `409 idempotency_key_in_progress`
        with `Retry-After: 1`. Answers `429` and `5xx` are not stored: retry them with the same key.
      schema: { type: string, maxLength: 255 }
      example: 2b5c0d2e-7d2a-4f6e-9a51-0c8a1e7f3b44

  responses:
    ProductWritten:
      description: The product as it is now, and what happened.
      content:
        application/json:
          schema:
            type: object
            required: [data, result]
            properties:
              data: { $ref: '#/components/schemas/Product' }
              result: { $ref: '#/components/schemas/WriteResult' }
    VariantWritten:
      description: The variant as it is now, and what happened.
      content:
        application/json:
          schema:
            type: object
            required: [data, result]
            properties:
              data: { $ref: '#/components/schemas/Variant' }
              result: { $ref: '#/components/schemas/WriteResult' }
    VariantsWritten:
      description: All variants of the product as they are now, and what happened.
      content:
        application/json:
          schema:
            type: object
            required: [data, result]
            properties:
              data:
                type: array
                items: { $ref: '#/components/schemas/Variant' }
              result: { $ref: '#/components/schemas/WriteResult' }
    LinesWritten:
      description: One result per line, in the order sent.
      content:
        application/json:
          schema:
            type: object
            required: [data]
            properties:
              data:
                type: array
                items: { $ref: '#/components/schemas/ItemResult' }
    ImageWritten:
      description: The image.
      content:
        application/json:
          schema:
            type: object
            required: [data]
            properties:
              data: { $ref: '#/components/schemas/Image' }
    BadRequest:
      description: |
        The request must change: `validation_failed` (see `details`, one list of messages per field),
        `invalid_json`, `invalid_cursor`, `product_not_written`, `variant_not_written`, `not_written`,
        `image_not_downloadable`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: validation_failed
              message: 'The request is not valid: see details.'
              request_id: 7c0f8a1e-3b2d-4e5f-9a6b-1c2d3e4f5a6b
              details:
                variants.0.prices.0.price_list: ['Unknown price list "usa". GET /price-lists lists them.']
    Unauthorized:
      description: '`unauthorized`: the key is missing, wrong or revoked.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ReadOnly:
      description: '`read_only_key`: this key cannot write.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: '`product_not_found`, `variant_not_found`, `image_not_found`, `import_not_found`, `webhook_not_found`, or `not_found` for a path that does not exist.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: |
        `field_rule` (the key's field rule does not allow this change), `field_locked` (the change touches
        fields another connected system owns; `details.locked` lists them), `idempotency_key_reused`,
        `idempotency_key_in_progress`, `webhook_limit`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooLarge:
      description: '`payload_too_large`: the body is larger than 20 MB.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: '`rate_limited`: wait the seconds in `Retry-After`.'
      headers:
        Retry-After:
          description: Seconds to wait.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { type: string, description: 'Stable machine-readable code. Branch on it, not on the message.' }
            message: { type: string }
            request_id: { type: string, description: 'Same as the `X-Request-Id` header. Quote it to support.' }
            details: { type: object, additionalProperties: true }

    Ping:
      type: object
      properties:
        api_version: { type: string, const: v1 }
        company: { type: object, properties: { id: { type: [integer, 'null'] } } }
        catalog: { type: object, properties: { id: { type: integer }, name: { type: string } } }
        key:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            level: { type: string, enum: [read, write] }
            field_rule: { $ref: '#/components/schemas/FieldRule' }
            rate_limit_per_minute: { type: integer }
        webhooks: { type: integer, description: 'Active webhooks of this key.' }
        server_time: { type: string, format: date-time }

    FieldRule:
      type: string
      enum: [sorgente, inserimento, nucleo]
      description: |
        `sorgente`: the API always updates the fields it sends. People in Nucleo can no longer edit the products it wrote. `inserimento`: the API only fills fields that
        are empty in Nucleo. `nucleo`: the API does not change existing fields.

    ProductInput:
      type: object
      description: Every field is optional on update; only the fields sent are written.
      properties:
        sku: { type: string, maxLength: 255, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$' }
        external_id: { type: string, maxLength: 255, description: 'Your id for the product. Remembered per key: later calls can send it instead of `sku`.' }
        title: { type: string, maxLength: 255 }
        description: { type: string, maxLength: 65000, description: 'HTML; unsafe markup is removed.' }
        status: { type: string, description: 'A status code of the catalog (for example `active`, `draft`). An unknown code is an error.' }
        product_type: { type: string, maxLength: 255, description: 'Created if new.' }
        brand: { type: string, maxLength: 255, description: 'Created if new.' }
        category: { type: string, maxLength: 500, description: 'A category path (`Clothing > Tops`): added to the product''s categories, created if new. To replace all categories use `PUT /products/{product}/categories`.' }
        model_code: { type: string, maxLength: 255 }
        gender: { type: string, maxLength: 255 }
        season: { type: string, maxLength: 255, description: 'One or more season codes, comma separated (`SS25, FW25`).' }
        color: { type: string, maxLength: 255 }
        material: { type: string, maxLength: 255 }
        made_in: { type: string, maxLength: 255, description: 'ISO country code or country name.' }
        composition: { type: string, maxLength: 2000 }
        attribute_set: { type: string, description: 'Name or id of the attribute set.' }
        attributes:
          type: object
          additionalProperties: true
          description: Values by attribute code (`GET /attributes`). An unknown code is an error.
        translations:
          type: object
          description: By locale code (`GET /locales`). An unknown locale is an error.
          additionalProperties:
            type: object
            properties:
              title: { type: string, maxLength: 255 }
              description: { type: string, maxLength: 65000 }
        image_urls:
          type: array
          maxItems: 50
          items: { type: string, format: uri }
          description: Linked as they are (not downloaded); a URL already on the product is not added again. To store a copy in Nucleo, use `POST /products/{product}/images`.
        variants:
          type: array
          maxItems: 500
          items: { $ref: '#/components/schemas/VariantInput' }

    VariantInput:
      type: object
      required: [sku]
      properties:
        sku: { type: string, maxLength: 255, pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$', description: 'Unique in the whole catalog.' }
        barcode: { type: string, maxLength: 255, description: 'Unique in the whole catalog.' }
        size: { type: string, maxLength: 255, description: 'Size code; becomes the variant''s size option (read back in `options`, as a normalised option code).' }
        cost: { type: number, minimum: 0 }
        weight: { type: number, minimum: 0 }
        weight_unit: { type: string, enum: [g, kg, lb, oz] }
        prices: { type: array, items: { $ref: '#/components/schemas/PriceInput' } }
        stock: { type: array, items: { $ref: '#/components/schemas/StockInput' } }

    PriceInput:
      type: object
      required: [price_list, price]
      properties:
        price_list: { type: string, description: 'Price list code (`GET /price-lists`).' }
        price: { type: number, minimum: 0 }
        sale_price: { type: [number, 'null'], minimum: 0, description: 'Discounted price on that list.' }

    StockInput:
      type: object
      required: [location, available]
      properties:
        location: { type: string, description: 'Location code (`GET /locations`).' }
        available: { type: integer, description: 'Replaces the quantity at that location.' }

    Product:
      type: object
      properties:
        id: { type: integer }
        sku: { type: string }
        external_id: { type: [string, 'null'], description: 'The id this key gave the product.' }
        title: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        handle: { type: [string, 'null'] }
        status: { type: [string, 'null'] }
        product_type: { type: [string, 'null'] }
        brand: { type: [string, 'null'] }
        attribute_set: { type: [string, 'null'] }
        model_code: { type: [string, 'null'] }
        gender: { type: [string, 'null'] }
        season: { type: [string, 'null'] }
        color: { type: [string, 'null'] }
        material: { type: [string, 'null'] }
        made_in: { type: [string, 'null'] }
        composition: { type: [string, 'null'] }
        categories: { type: array, items: { $ref: '#/components/schemas/ProductCategory' } }
        attributes:
          type: object
          additionalProperties: { type: string }
          description: Values by attribute code. Options as their code; several options as their codes, comma separated.
        translations:
          type: object
          description: Locales other than the default one.
          additionalProperties:
            type: object
            properties:
              title: { type: [string, 'null'] }
              description: { type: [string, 'null'] }
        images: { type: array, items: { $ref: '#/components/schemas/Image' } }
        variants: { type: array, items: { $ref: '#/components/schemas/Variant' } }
        updated_at: { type: [string, 'null'], format: date-time, description: 'When the product last changed; `null` if it has not changed since this field exists.' }

    DeletedProduct:
      type: object
      required: [id, sku, deleted, updated_at]
      properties:
        id: { type: integer }
        sku: { type: [string, 'null'] }
        deleted: { type: boolean, const: true }
        updated_at: { type: string, format: date-time }

    Variant:
      type: object
      properties:
        id: { type: integer }
        sku: { type: string }
        barcode: { type: [string, 'null'] }
        options: { type: object, additionalProperties: { type: string }, description: 'The options that tell variants apart: attribute code → option code (`{"size": "m"}`). Option codes are normalised, so a size sent as `M` comes back as `m`.' }
        cost: { type: [string, 'null'] }
        weight: { type: [number, 'null'] }
        weight_unit: { type: [string, 'null'] }
        prices:
          type: array
          items:
            type: object
            properties:
              price_list: { type: string }
              currency: { type: [string, 'null'] }
              price: { type: [string, 'null'], example: '19.90' }
              sale_price: { type: [string, 'null'] }
        stock:
          type: array
          items:
            type: object
            properties:
              location: { type: string }
              available: { type: integer }
              on_hand: { type: integer }

    Image:
      type: object
      properties:
        id: { type: integer }
        url: { type: string }
        alt: { type: [string, 'null'] }
        position: { type: integer }
        width: { type: [integer, 'null'] }
        height: { type: [integer, 'null'] }

    ProductCategory:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        path: { type: string, example: Clothing > Tops }

    WriteResult:
      type: object
      required: [status, errors, notes]
      properties:
        status:
          type: string
          enum: [created, updated, partial, skipped]
          description: |
            `partial`: some variants could not be written (see `errors`), the rest was. `skipped`: the key's
            field rule left nothing to write.
        errors:
          type: array
          items:
            type: object
            properties:
              variant_sku: { type: [string, 'null'] }
              message: { type: string }
        notes:
          type: array
          items: { type: string }
          description: Fields not written and why (field rule, field owned by another system).

    ItemResult:
      type: object
      required: [index, sku, status, errors, notes]
      properties:
        index: { type: integer, description: 'Position in the request, from 0.' }
        sku: { type: [string, 'null'] }
        product_id: { type: [integer, 'null'] }
        status:
          type: string
          enum: [created, updated, partial, skipped, failed, invalid]
          description: '`invalid`: not written because the input is wrong. `failed`: nothing could be written.'
        errors:
          type: array
          items:
            type: object
            properties:
              field: { type: string, description: 'For `invalid`, the field in error.' }
              variant_sku: { type: [string, 'null'] }
              message: { type: string }
        notes: { type: array, items: { type: string } }

    Summary:
      type: object
      properties:
        created: { type: integer }
        updated: { type: integer }
        partial: { type: integer }
        skipped: { type: integer }
        failed: { type: integer }
        invalid: { type: integer }

    Import:
      type: object
      required: [id, status, total, processed, failed]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [queued, processing, done, partial, failed] }
        total: { type: integer }
        processed: { type: integer }
        failed: { type: integer, description: 'Products `invalid` or `failed`.' }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }

    EventType:
      type: string
      enum: [product.created, product.updated, product.deleted, stock.updated]

    Webhook:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        events: { type: array, items: { $ref: '#/components/schemas/EventType' } }
        active: { type: boolean }
        last_success_at: { type: [string, 'null'], format: date-time }
        last_failure_at: { type: [string, 'null'], format: date-time }
        last_error: { type: [string, 'null'], example: HTTP 503 }
        created_at: { type: string, format: date-time }

    Event:
      type: object
      required: [id, type, created_at, triggered_by_this_key, data]
      properties:
        id: { type: string, format: uuid, description: 'Same at every attempt.' }
        type: { $ref: '#/components/schemas/EventType' }
        created_at: { type: string, format: date-time }
        triggered_by_this_key: { type: boolean }
        data:
          type: object
          properties:
            product_id: { type: integer }
            sku: { type: [string, 'null'] }
            changed_fields: { type: array, items: { type: string }, description: 'Only in `product.updated`.' }
            variant_id: { type: integer, description: 'Only in `stock.updated`.' }
            variant_sku: { type: [string, 'null'] }
            location: { type: string }
            available: { type: [integer, 'null'] }
