openapi: 3.1.0
info:
  title: Nucleo Collector API
  version: v1
  summary: Send storefront events and consent to Nucleo Catalog, and read the size widget.
  description: |
    The Collector is the public, browser-facing door of **Nucleo Catalog**. A storefront sends behavioural
    events in batches (page and product views, searches, cart changes, checkout and purchase), reports
    consent changes, checks that it is connected, and reads the size recommendation widget for a product page.

    It is called from shoppers' browsers — normally by the hosted script `nucleo.js` and by the Shopify
    checkout Web Pixel — and can be called by any server for testing.

    **Authentication** is a pair: the installation's **public key** (`pk_` + 32 hex characters) plus an
    **allowed origin** (one of the domains registered on the installation). The key is public and grants no
    read access to your data; the domain list is what protects it.

    **Privacy by design**: nothing is written without `granted` consent, a shopper's identity is only accepted
    as a reference **signed by your store**, and it is only linked when the shopper granted the
    `personalisation` purpose.
  contact:
    name: Nucleo
    url: https://nucleoplatform.com
x-nucleo:
  product: collector
  module: catalog
  audience: [storefront]
  stability: beta
  format: json
  order: 10
servers:
  - url: https://api-catalog.nucleoplatform.com/api/collect/v1
    description: Production
externalDocs:
  description: The storefront script (served next to the API)
  url: https://api-catalog.nucleoplatform.com/collect/v1/nucleo.js
tags:
  - name: Events
    description: |
      Batches of storefront events. Each batch is validated **event by event**: an event with an unknown kind,
      a malformed `uid` or a timestamp out of range is rejected on its own and the rest of the batch is stored.
      Only batch-level problems (no visitor id, invalid consent state, more than 50 events, bad test code,
      unsupported version) fail the whole batch with a `422`.
  - name: Consent
    description: |
      Consent changes for a visitor. `granted` creates or refreshes the visitor; `denied` and `revoked`
      **erase** everything stored about that visitor and leave only a hashed tombstone for 30 days; without a
      visitor id they are counted anonymously so you can measure opt-outs without storing anything.
  - name: Installation
    description: Connectivity check for the installation behind a public key.
  - name: Widgets
    description: |
      Read-only widgets for product pages, served with the same public key, origin check and consent rules
      as the events.
paths:
  /events:
    post:
      operationId: sendEvents
      summary: Send a batch of events
      tags: [Events]
      description: |
        Sends up to **50 events** for one visitor in a single batch (body ≤ **64 KB**).

        **Transport.** Browsers should send the body as `text/plain` (e.g. `navigator.sendBeacon` or
        `fetch(..., {mode: "no-cors", credentials: "omit"})`) with the key in the `key` body field: a
        `text/plain` POST without custom headers is a CORS *simple request* and needs no preflight. The
        Collector does **not** answer CORS preflights, so `application/json` or the `X-Nucleo-Key` header only
        work from servers and tools such as `curl`.

        **Origin.** On POST only the `Origin` header counts (a `Referer` alone is refused). `Origin: null`
        (the sandboxed Shopify Web Pixel) is accepted only when the installation has the Web Pixel enabled,
        and then only `checkout_start` and `purchase` events pass (other kinds are rejected with reason
        `origin`) and the channel is recorded as `web-pixel`.

        **Consent.** If `visitor.consent` is not `granted` (compared after trimming and lower-casing) nothing is
        written — not even the visitor — and the response is `202` with `stored: 0` and `reason: "consent"`. If it
        is `denied` or `revoked` and the visitor is already known, the erasure described in
        [`POST /consent`](#tag/Consent) is applied.

        **Validation per event.**
        - `kind` must be one of the seven supported kinds (`kind`).
        - `uid` is optional; when present it must match `^[A-Za-z0-9_-]{8,40}$` (`uid`).
        - `at` is optional (defaults to now) and must be RFC 3339; it must fall between **7 days ago** and
          **5 minutes from now** (`time`). If the batch carries `sentAt` and the browser clock differs from
          ours by more than 30 seconds and at most 24 hours, every `at` is shifted by the difference before the
          window check (the original value is kept as `payload._client_at`, the shift is returned in `skew`).
        - An event that is not a JSON object is rejected (`shape`).
        - `payload` must be a flat object of scalars with at most 20 keys and strings ≤ 500 characters;
          otherwise the payload is dropped (the event is kept) and its index is listed in `payloadDropped`.
          Only the keys allowed for the kind are kept (see each event schema); others are silently dropped and
          the event index is also listed in `payloadDropped`. Keys starting with `_` are reserved.
        - `page_view.path` and `product_view.from` are stored without fragment and query string (except `q`);
          under `/account`, `/checkout`, `/checkouts`, `/orders` and `/customer` only the first segment is kept.
        - `query` (search only) is truncated to 200 characters with whitespace collapsed. A query that looks like
          personal data — an email address, 8+ consecutive digits, or a phone-like digit sequence (9+ digits
          with separators, or 6+ digits starting with `+`) — is **withheld**: stored as `null`, flagged, and
          its index listed in `withheld`.
        - Control characters (C0 and DEL) are stripped from every string.

        **Idempotency.** An event with a `uid` is written at most once: re-sending it (or repeating it in the
        same batch) counts it in `duplicates`. Always set a random `uid` so retries are safe.

        **Identity.** `customer` is a reference signed by your store (see `CustomerReference`). It is verified,
        and the visitor is linked to the customer **only** if `visitor.purposes` contains `personalisation`;
        otherwise the events stay anonymous and `identityWithheld` is `true`. A plain customer id is never
        accepted.

        **Product references.** `ref` is matched to your Catalog by variant id, then SKU, then product id, then
        handle. Events whose reference does not match any product are stored anyway and counted in `unmapped`.

        **Carts.** When `payload.cart` is present, `add_to_cart` / `remove_from_cart` / `checkout_start` /
        `purchase` update the visitor's cart. A cart token that belongs to another visitor is not touched (the
        event is still stored) and counted in `cartRefused`.

        **Test mode.** With a `test` code (6 characters `[A-Z0-9]`, generated in *Catalog › Shoppers › Test
        events*) the batch runs the full validation and product matching but **writes nothing**; the result
        appears live in Test events and the response carries `test: true`. An expired or unknown code still
        keeps the batch in test mode (`testKnown: false`).

        A database error never produces a `500`: the batch comes back `202` with every event rejected with
        reason `storage`.

        **Rate limits.** 120 requests per minute per client IP, plus an emergency cap of 20,000 requests per
        minute per public key. The limits are shared by all Collector operations and are checked before the
        key is looked up.
      x-rate-limit:
        limit: 120
        window: 1m
        scope: per IP
      x-rate-limit-key:
        limit: 20000
        window: 1m
        scope: per key
      security:
        - publicKeyQuery: []
        - publicKeyHeader: []
      parameters:
        - $ref: '#/components/parameters/Origin'
      requestBody:
        required: true
        description: |
          A batch of events. Send it as `text/plain` from browsers (the body is still JSON) or as
          `application/json` from servers. Maximum 64 KB.
        content:
          text/plain:
            schema:
              $ref: '#/components/schemas/EventBatch'
            examples:
              browsing:
                $ref: '#/components/examples/BrowsingBatch'
          application/json:
            schema:
              $ref: '#/components/schemas/EventBatch'
            examples:
              browsing:
                $ref: '#/components/examples/BrowsingBatch'
              purchase:
                summary: Checkout and purchase from the Web Pixel
                value:
                  key: pk_3f9a1c7e5b2d4f60a8c1e9b7d3f5a2c4
                  v: 1
                  sentAt: '2026-10-04T09:42:10Z'
                  visitor:
                    id: 8b1f0c2e-9d7a-4b3c-6e5f-1a2b3c4d5e6f
                    consent: granted
                    purposes: [analytics, personalisation]
                    consentSource: shopify
                  events:
                    - uid: px-2c7e91b4a0
                      kind: checkout_start
                      at: '2026-10-04T09:40:55Z'
                      payload:
                        cart: c1-9f2b7e4a1d
                        checkout: ck-5e1a9c3b7d
                        currency: EUR
                        items: 2
                    - uid: px-4d0a63f8c1
                      kind: purchase
                      at: '2026-10-04T09:42:01Z'
                      payload:
                        cart: c1-9f2b7e4a1d
                        checkout: ck-5e1a9c3b7d
                        order_id: 5901234567
                        order: ACM-1042
                        currency: EUR
                        items: 2
              testMode:
                summary: A batch in test mode (nothing is written)
                value:
                  key: pk_3f9a1c7e5b2d4f60a8c1e9b7d3f5a2c4
                  v: 1
                  test: K7Q2ZP
                  visitor:
                    id: 4e6c1a9b2d7f3e5a8c0b1d2e3f4a5b6c
                    consent: granted
                    purposes: [analytics]
                  events:
                    - uid: e-91ab03c4ff
                      kind: search
                      query: linen shirt
                      payload:
                        results: 18
      responses:
        '202':
          description: |
            The batch was received. Inspect the counters to see what was stored: the storefront usually does not
            read the response (`no-cors`, `sendBeacon`), it is there for testing.
          headers:
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/AccessControlAllowOrigin'
            Vary:
              $ref: '#/components/headers/Vary'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/EventsResult'
                  - $ref: '#/components/schemas/EventsNoConsentResult'
                  - $ref: '#/components/schemas/EventsStorageResult'
              examples:
                stored:
                  summary: Batch stored
                  value:
                    accepted: 4
                    stored: 3
                    duplicates: 1
                    rejected:
                      - index: 4
                        reason: kind
                    payloadDropped: [1]
                    withheld: []
                    skew: 0
                    unmapped: 0
                    cartRefused: 0
                    identified: true
                    identityWithheld: false
                    visitor: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
                noConsent:
                  summary: Consent not granted, nothing written
                  value:
                    accepted: 3
                    stored: 0
                    reason: consent
                    rejected: []
                    visitor: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
                testMode:
                  summary: Test mode (dry run)
                  value:
                    accepted: 1
                    stored: 0
                    duplicates: 0
                    rejected: []
                    payloadDropped: []
                    withheld: []
                    skew: 0
                    unmapped: 0
                    cartRefused: 0
                    identified: false
                    identityWithheld: false
                    visitor: 4e6c1a9b2d7f3e5a8c0b1d2e3f4a5b6c
                    test: true
                    testKnown: true
                storage:
                  summary: The database refused the batch
                  value:
                    accepted: 2
                    stored: 0
                    rejected:
                      - index: 0
                        reason: storage
                      - index: 1
                        reason: storage
                    visitor: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/StoreNotReady'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: |
            The batch as a whole is not valid. Nothing is written. Note that `errors` maps each field to a
            **single message string** (not an array).
          headers:
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/AccessControlAllowOrigin'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CollectorValidationError'
              examples:
                missingVisitor:
                  summary: Missing visitor id
                  value:
                    message: The batch is not valid.
                    errors:
                      visitor.id: 'visitor.id is required: 16-64 characters of [A-Za-z0-9_-].'
                tooManyEvents:
                  summary: More than 50 events
                  value:
                    message: The batch is not valid.
                    errors:
                      events: A batch holds at most 50 events.
                badConsent:
                  summary: Invalid consent state and test code
                  value:
                    message: The batch is not valid.
                    errors:
                      visitor.consent: 'visitor.consent must be one of: granted, denied, pending, revoked.'
                      test: 'test must be a 6-character code of [A-Z0-9].'
                reservedPrefix:
                  summary: Reserved visitor id prefix
                  value:
                    message: The batch is not valid.
                    errors:
                      visitor.id: 'visitor.id must not start with "demo": the prefix is reserved for the simulated signal.'
                version:
                  summary: Unsupported version
                  value:
                    message: The batch is not valid.
                    errors:
                      v: 'Unsupported batch version: only v=1 is accepted.'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /consent:
    post:
      operationId: updateConsent
      summary: Report a consent change
      tags: [Consent]
      description: |
        Reports the visitor's consent state. Same transport, key, origin rules and rate limits as
        [`POST /events`](#tag/Events).

        | `consent` | with `visitor.id` | without `visitor.id` |
        |---|---|---|
        | `granted` | creates or refreshes the visitor with its purposes and source; counted once per visitor | `422` (an id is required) |
        | `denied` / `revoked` | if the visitor exists: **deletes** its events, identities and carts (carts already confirmed by Shopify's server are kept but detached from the person) and keeps a tombstone (`sha256` of the id) for 30 days; always counted | counted anonymously, nothing written |
        | `pending` | nothing written, nothing counted | nothing written, nothing counted |

        A later `granted` with the same id starts a brand-new visitor. Purposes are read from `purposes`, or
        from `visitor.purposes` when absent; the source from `visitor.consentSource`, or `consentSource`.

        With a `test` code nothing is written or counted and the response carries `test: true`.

        **Rate limits.** Shared with all Collector operations: 120 requests per minute per IP, 20,000 per minute
        per key.
      x-rate-limit:
        limit: 120
        window: 1m
        scope: per IP
      x-rate-limit-key:
        limit: 20000
        window: 1m
        scope: per key
      security:
        - publicKeyQuery: []
        - publicKeyHeader: []
      parameters:
        - $ref: '#/components/parameters/Origin'
      requestBody:
        required: true
        content:
          text/plain:
            schema:
              $ref: '#/components/schemas/ConsentUpdate'
            examples:
              revoked:
                $ref: '#/components/examples/ConsentRevoked'
          application/json:
            schema:
              $ref: '#/components/schemas/ConsentUpdate'
            examples:
              granted:
                summary: Consent granted
                value:
                  key: pk_3f9a1c7e5b2d4f60a8c1e9b7d3f5a2c4
                  sentAt: '2026-10-04T09:30:00Z'
                  visitor:
                    id: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
                    consentSource: shopify
                  consent: granted
                  purposes: [analytics, personalisation]
              revoked:
                $ref: '#/components/examples/ConsentRevoked'
              anonymousDenied:
                summary: Denied before any visitor id existed
                value:
                  key: pk_3f9a1c7e5b2d4f60a8c1e9b7d3f5a2c4
                  visitor:
                    consentSource: gpc
                  consent: denied
                  purposes: []
      responses:
        '200':
          description: The consent change was applied (or counted).
          headers:
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/AccessControlAllowOrigin'
            Vary:
              $ref: '#/components/headers/Vary'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConsentResult'
              examples:
                granted:
                  summary: Granted
                  value:
                    visitor: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
                    consent: granted
                    stored: true
                    counted: true
                revokedKnown:
                  summary: Revoked, visitor erased
                  value:
                    visitor: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
                    consent: revoked
                    deleted:
                      events: 57
                      identities: 1
                      carts: 2
                    counted: true
                revokedUnknown:
                  summary: Denied, visitor unknown or no id
                  value:
                    visitor: null
                    consent: denied
                    stored: false
                    counted: true
                pending:
                  summary: Pending
                  value:
                    visitor: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
                    consent: pending
                    stored: false
                    counted: false
                testMode:
                  summary: Test mode
                  value:
                    visitor: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
                    consent: granted
                    stored: false
                    counted: false
                    test: true
                    testKnown: false
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/StoreNotReady'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          description: The consent update is not valid. `errors` maps each field to a single message string.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CollectorValidationError'
              examples:
                grantWithoutId:
                  summary: Granted without a visitor id
                  value:
                    message: The consent update is not valid.
                    errors:
                      visitor.id: 'visitor.id is required to grant consent: 16-64 characters of [A-Za-z0-9_-].'
                badState:
                  summary: Unknown state
                  value:
                    message: The consent update is not valid.
                    errors:
                      consent: 'consent must be one of: granted, denied, pending, revoked.'
                malformedId:
                  summary: Malformed visitor id
                  value:
                    message: The consent update is not valid.
                    errors:
                      visitor.id: 'visitor.id must be 16-64 characters of [A-Za-z0-9_-].'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /config:
    get:
      operationId: getInstallationConfig
      summary: Check the installation is connected
      tags: [Installation]
      description: |
        Returns the installation behind the key: the script uses it to know it is connected, and you can open it
        from the browser address bar while testing. For this GET the origin is taken from `Origin`, or from
        `Referer` when `Origin` is absent. A paused installation answers `403`.

        **Rate limits.** Shared with all Collector operations: 120 requests per minute per IP, 20,000 per minute
        per key.
      x-rate-limit:
        limit: 120
        window: 1m
        scope: per IP
      x-rate-limit-key:
        limit: 20000
        window: 1m
        scope: per key
      security:
        - publicKeyQuery: []
        - publicKeyHeader: []
      parameters:
        - $ref: '#/components/parameters/Origin'
        - $ref: '#/components/parameters/Referer'
      responses:
        '200':
          description: The installation is active and the origin is allowed.
          headers:
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/AccessControlAllowOrigin'
            Vary:
              $ref: '#/components/headers/Vary'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstallationConfig'
              examples:
                connected:
                  value:
                    ok: true
                    store: acme
                    installation: Acme EU storefront
                    status: active
                    locales: [it, en]
                    pixelEnabled: true
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/StoreNotReady'
        '429':
          $ref: '#/components/responses/TooManyRequests'
  /size/{product}:
    get:
      operationId: getSizeAdvice
      summary: Get size advice for a product
      tags: [Widgets]
      description: |
        Powers a size selector on the product page: the sizes the product is sold in, **how it fits** (from size
        swaps observed across shoppers, the same for everyone) and, when every condition below holds, the size
        **recommended to the shopper who is looking**.

        The personal advice appears only if:
        1. a `customer` reference **signed by your store** is supplied and verifies (a plain id is ignored);
        2. the `visitor` has granted consent including the `personalisation` purpose (recorded via
           [`POST /consent`](#tag/Consent) or an events batch);
        3. the customer has enough purchase history.

        Otherwise the answer stays impersonal and `why` says which condition was missing. A failed verification
        is never an error. The response never contains the customer's size profile or purchase list, and the
        call is not logged against the customer.

        Only published products answer with sizes: a draft, archived or status-less product (or an unknown id)
        answers like a product without sizes (`why: "no-sizes"`), so the widget can hide itself.

        Same key and origin rules as the other operations (for this GET, `Referer` is accepted when `Origin` is
        absent). From a browser, pass `key`, `visitor` and `customer` as query parameters: custom headers trigger
        a CORS preflight that the Collector does not answer. The customer reference is short-lived (15 minutes by
        default), so it is harmless in access logs.

        **Rate limits.** Shared with all Collector operations: 120 requests per minute per IP, 20,000 per minute
        per key.
      x-rate-limit:
        limit: 120
        window: 1m
        scope: per IP
      x-rate-limit-key:
        limit: 20000
        window: 1m
        scope: per key
      security:
        - publicKeyQuery: []
        - publicKeyHeader: []
      parameters:
        - name: product
          in: path
          required: true
          description: The **Nucleo Catalog** product id (numeric), not the Shopify product id.
          schema:
            type: integer
            minimum: 1
          example: 4821
        - name: visitor
          in: query
          required: false
          description: The visitor id used in the events batches. Used only to read the visitor's consent.
          schema:
            type: string
            maxLength: 64
          example: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
        - name: customer
          in: query
          required: false
          description: |
            The signed customer reference (see `CustomerReference`). Ignored when the `X-Nucleo-Customer-Ref`
            header is present.
          schema:
            type: string
            maxLength: 512
          example: v1.12.7012345678.1791104400.9c1e4b7a2f0d3c6e8b5a1f4d7c0e3b6a9d2f5c8e1b4a7d0c3f6e9b2a5d8c1f4e
        - name: X-Nucleo-Customer-Ref
          in: header
          required: false
          description: The signed customer reference, for server-side callers. Takes precedence over `customer` (max 512 characters).
          schema:
            type: string
            maxLength: 512
        - $ref: '#/components/parameters/Origin'
        - $ref: '#/components/parameters/Referer'
      responses:
        '200':
          description: Always answered, with or without a personal recommendation.
          headers:
            Access-Control-Allow-Origin:
              $ref: '#/components/headers/AccessControlAllowOrigin'
            Vary:
              $ref: '#/components/headers/Vary'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SizeWidgetResponse'
              examples:
                personalised:
                  summary: Personal advice
                  value:
                    data:
                      product:
                        id: 4821
                        sizes: [XS, S, M, L, XL]
                      reading:
                        direction: runs_large
                        basis: product
                        people: 64
                        share: 0.297
                        base: 0.512
                        fit: oversize
                        segment: man
                        says: 'Runs large: 70% of the 64 shoppers who swapped size on this item kept the smaller one. Most people do better one size down from their usual.'
                      advice:
                        size: M
                        usual: L
                        step: -1
                        segment: man
                        scale: alpha
                        basis: product
                        people: 64
                        share: 0.297
                        base: 0.512
                        fit: oversize
                        confident: true
                        kept: 6
                        seen: 7
                        reason: step
                        says: 'Take M instead of their usual L: 70% of the 64 shoppers who swapped size on this item kept the smaller one.'
                      personalised: true
                      why: null
                anonymous:
                  summary: Impersonal (no customer reference)
                  value:
                    data:
                      product:
                        id: 4821
                        sizes: [XS, S, M, L, XL]
                      reading:
                        direction: as_expected
                        basis: gender-fit
                        people: 412
                        share: 0.508
                        base: 0.512
                        fit: regular
                        segment: woman
                        says: 'Fits as expected: the 412 shoppers who swapped size on 37 items that fit the same way split the same way as the rest of the range. Take your usual size.'
                      advice: null
                      personalised: false
                      why: anonymous
                noSizes:
                  summary: Product without sizes, unpublished or unknown
                  value:
                    data:
                      product:
                        id: 4821
                        sizes: []
                      reading: null
                      advice: null
                      personalised: false
                      why: no-sizes
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/StoreNotReady'
        '422':
          description: A query parameter is too long (standard validation error shape).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
              examples:
                visitorTooLong:
                  value:
                    message: The visitor field must not be greater than 64 characters.
                    errors:
                      visitor:
                        - The visitor field must not be greater than 64 characters.
        '429':
          $ref: '#/components/responses/TooManyRequests'
components:
  securitySchemes:
    publicKeyHeader:
      type: apiKey
      in: header
      name: X-Nucleo-Key
      description: |
        The public key as a header — checked first. For servers and tools only: from a browser a custom header
        triggers a CORS preflight, which the Collector does not answer.
    publicKeyQuery:
      type: apiKey
      in: query
      name: key
      description: |
        The installation's public key: `pk_` followed by 32 lowercase hex characters. A missing or malformed key
        answers `404`.

        It is read, in order, from the `X-Nucleo-Key` header, then from the **`key` field of the JSON body**
        (the browser transport for `POST /events` and `POST /consent`, since `sendBeacon` cannot set headers),
        then from the `?key=` query parameter (the usual way for the GET operations).

        Get it in **Settings › Catalog › Search › Installations** (owner or admin role): each installation shows
        its public key, allowed domains and a ready-to-paste `<script>` snippet. Rotating the key disables the
        old one immediately. The key is public by design; the allowed domains are what protect it.
  parameters:
    Origin:
      name: Origin
      in: header
      required: false
      description: |
        Set by the browser. Its host must equal one of the installation's domains, or be a subdomain of a
        wildcard domain (`*.acme.example` matches `shop.acme.example` and `eu.shop.acme.example`, not
        `acme.example`). `null` is accepted only when the installation has the Web Pixel enabled. An origin
        that does not match answers `403`.
      schema:
        type: string
      example: https://shop.acme.example
    Referer:
      name: Referer
      in: header
      required: false
      description: On GET operations only, used for the origin check when `Origin` is absent.
      schema:
        type: string
      example: https://shop.acme.example/products/classic-tee-black
  headers:
    AccessControlAllowOrigin:
      description: Echoes the request `Origin` when it was present and allowed.
      schema:
        type: string
      example: https://shop.acme.example
    Vary:
      description: Always `Origin`.
      schema:
        type: string
      example: Origin
    XRateLimitLimit:
      description: Requests allowed per minute for the client IP.
      schema:
        type: integer
      example: 120
    XRateLimitRemaining:
      description: Requests left in the current minute.
      schema:
        type: integer
      example: 117
    RetryAfter:
      description: Seconds to wait before retrying.
      schema:
        type: integer
      example: 42
    XRateLimitReset:
      description: Unix timestamp at which the window resets.
      schema:
        type: integer
      example: 1791104442
  responses:
    NotFound:
      description: The key is missing, malformed or unknown (no detail is given on purpose).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            notFound:
              value:
                message: Not found.
    Forbidden:
      description: |
        The installation is paused, or the origin is not one of its domains. Both answer the same body; the
        exact reason is visible to the merchant in *Catalog › Shoppers › Test events*.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            forbidden:
              value:
                message: Forbidden.
    StoreNotReady:
      description: The store behind the key is not provisioned yet.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            notReady:
              value:
                message: Store is not ready.
    PayloadTooLarge:
      description: The body exceeds 64 KB.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            tooLarge:
              value:
                message: Payload too large.
    TooManyRequests:
      description: |
        Rate limit exceeded (120 requests per minute per IP, or 20,000 per minute per key). Nothing is written.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          description: Always `0` on a 429.
          schema:
            type: integer
          example: 0
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Message'
          examples:
            throttled:
              value:
                message: Too Many Attempts.
  examples:
    BrowsingBatch:
      summary: A browsing session from the storefront script
      value:
        key: pk_3f9a1c7e5b2d4f60a8c1e9b7d3f5a2c4
        v: 1
        sentAt: '2026-10-04T09:31:02.418Z'
        visitor:
          id: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
          consent: granted
          purposes: [analytics, personalisation]
          consentSource: shopify
          locale: it
          channel: web
        session: s_4c2a9e1b7f3d5a60
        customer: v1.12.7012345678.1791104400.9c1e4b7a2f0d3c6e8b5a1f4d7c0e3b6a9d2f5c8e1b4a7d0c3f6e9b2a5d8c1f4e
        events:
          - uid: e-7f3a9c1b20
            kind: page_view
            at: '2026-10-04T09:30:12.004Z'
            market: IT
            payload:
              type: collection
              path: /collections/men
              collection: men
              filters: filter.v.option.color=Black;filter.v.price.lte=80
              sort: price-ascending
          - uid: e-7f3a9c1b21
            kind: product_view
            at: '2026-10-04T09:30:40.551Z'
            ref:
              product: '8123456789'
              variant: '44001234567'
              handle: classic-tee-black
              sku: TEE-BLK-M
            payload:
              price: '29.00'
              currency: EUR
              pos: 3
              list: collection:men
              from: /collections/men
          - uid: e-7f3a9c1b22
            kind: search
            at: '2026-10-04T09:30:55.120Z'
            query: black hoodie
            payload:
              results: 12
          - uid: e-7f3a9c1b23
            kind: add_to_cart
            at: '2026-10-04T09:31:01.870Z'
            ref:
              variant: '44001234567'
              sku: TEE-BLK-M
            payload:
              cart: c1-9f2b7e4a1d
              quantity: 1
              price: '29.00'
              currency: EUR
    ConsentRevoked:
      summary: Consent revoked
      value:
        key: pk_3f9a1c7e5b2d4f60a8c1e9b7d3f5a2c4
        sentAt: '2026-10-04T10:02:00Z'
        visitor:
          id: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
          consentSource: shopify
        consent: revoked
        purposes: []
  schemas:
    PublicKey:
      type: string
      pattern: '^pk_[0-9a-f]{32}$'
      description: The installation's public key.
      example: pk_3f9a1c7e5b2d4f60a8c1e9b7d3f5a2c4
    VisitorId:
      type: string
      pattern: '^[A-Za-z0-9_-]{16,64}$'
      description: |
        Opaque browser identifier generated by the storefront: Shopify's `_shopify_y` cookie value (so the theme
        and the checkout Web Pixel share the same visitor) or 32 random hex characters. Ids starting with `demo`
        (any case) are reserved and rejected.
      example: 8b1f0c2e9d7a4b3c6e5f1a2b3c4d5e6f
    ConsentState:
      type: string
      enum: [granted, denied, pending, revoked]
      description: Case-insensitive; surrounding spaces are ignored.
    Purposes:
      type: array
      maxItems: 10
      description: |
        Consent purposes. Only `personalisation` changes behaviour: without it events stay anonymous and the size
        widget stays impersonal. Items beyond the 10th are ignored; each item is trimmed to 32 characters. The
        storefront script maps Shopify's `analyticsProcessingAllowed()` to `analytics` and `marketingAllowed()`
        to `personalisation`.
      items:
        type: string
        maxLength: 32
        examples: [analytics, personalisation]
      example: [analytics, personalisation]
    ConsentSource:
      type: string
      enum: [shopify, manual, gpc]
      description: Where the consent decision came from. Any other value is ignored (stored as null).
    TestCode:
      type: string
      pattern: '^[A-Z0-9]{6}$'
      description: |
        Test code from *Catalog › Shoppers › Test events* (valid for 60 minutes, one per store). Trimmed and
        upper-cased before validation. A present but malformed value fails the request with `422`. The storefront
        script also picks it up from `?nucleo_test=<code>` in any page URL.
      example: K7Q2ZP
    CustomerReference:
      type: string
      maxLength: 200
      description: |
        A customer reference **signed by your store**, in the form
        `v1.<storeId>.<shopifyCustomerId>.<expiresAt>.<signature>` where `expiresAt` is a Unix timestamp
        (at most 24 hours ahead; 15 minutes is recommended) and `signature` is the lowercase hex
        `HMAC-SHA256` of `v1.<storeId>.<shopifyCustomerId>.<expiresAt>` with the store's storefront secret.
        In a Shopify theme it is produced with Liquid's `hmac_sha256` filter and exposed as
        `<meta name="nucleo-customer" content="…">`, which the script picks up. Wrong store, expired, malformed
        or badly signed references are treated as anonymous, never as errors.
      example: v1.12.7012345678.1791104400.9c1e4b7a2f0d3c6e8b5a1f4d7c0e3b6a9d2f5c8e1b4a7d0c3f6e9b2a5d8c1f4e
    Timestamp:
      type: string
      format: date-time
      pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$'
      maxLength: 40
      description: RFC 3339 with a `Z` or an explicit offset, as produced by `Date.prototype.toISOString()`.
      example: '2026-10-04T09:30:12.004Z'
    Visitor:
      type: object
      required: [id, consent]
      properties:
        id:
          $ref: '#/components/schemas/VisitorId'
        consent:
          $ref: '#/components/schemas/ConsentState'
        purposes:
          $ref: '#/components/schemas/Purposes'
        consentSource:
          $ref: '#/components/schemas/ConsentSource'
        locale:
          type: string
          maxLength: 16
          description: Default locale for the batch's events (truncated to 16 characters).
          example: it
        channel:
          type: string
          maxLength: 64
          description: Default channel for the batch's events (truncated to 64 characters).
          example: web
    EventBatch:
      type: object
      required: [visitor]
      properties:
        key:
          $ref: '#/components/schemas/PublicKey'
        v:
          description: Contract version. Optional; when present must be `1` (number or string).
          oneOf:
            - type: integer
              const: 1
            - type: string
              const: '1'
          example: 1
        sentAt:
          allOf:
            - $ref: '#/components/schemas/Timestamp'
          description: |
            Browser time when the batch was sent. Used to correct clock skew between 30 seconds and 24 hours.
            An unreadable value is ignored.
        visitor:
          $ref: '#/components/schemas/Visitor'
        session:
          type: string
          pattern: '^[A-Za-z0-9_-]{16,64}$'
          description: Optional session id (same format as the visitor id). A malformed value is ignored.
          example: s_4c2a9e1b7f3d5a60
        customer:
          $ref: '#/components/schemas/CustomerReference'
        test:
          $ref: '#/components/schemas/TestCode'
        events:
          type: array
          maxItems: 50
          default: []
          description: The events, in any order. An empty list is valid.
          items:
            $ref: '#/components/schemas/Event'
    Event:
      description: One storefront event. The allowed `payload` keys depend on `kind`.
      oneOf:
        - $ref: '#/components/schemas/PageViewEvent'
        - $ref: '#/components/schemas/ProductViewEvent'
        - $ref: '#/components/schemas/SearchEvent'
        - $ref: '#/components/schemas/AddToCartEvent'
        - $ref: '#/components/schemas/RemoveFromCartEvent'
        - $ref: '#/components/schemas/CheckoutStartEvent'
        - $ref: '#/components/schemas/PurchaseEvent'
      discriminator:
        propertyName: kind
        mapping:
          page_view: '#/components/schemas/PageViewEvent'
          product_view: '#/components/schemas/ProductViewEvent'
          search: '#/components/schemas/SearchEvent'
          add_to_cart: '#/components/schemas/AddToCartEvent'
          remove_from_cart: '#/components/schemas/RemoveFromCartEvent'
          checkout_start: '#/components/schemas/CheckoutStartEvent'
          purchase: '#/components/schemas/PurchaseEvent'
    EventBase:
      type: object
      required: [kind]
      properties:
        uid:
          type: string
          pattern: '^[A-Za-z0-9_-]{8,40}$'
          description: Optional idempotency id. Strongly recommended — a malformed value rejects the event (`uid`).
          example: e-7f3a9c1b20
        kind:
          type: string
          enum: [page_view, product_view, search, add_to_cart, remove_from_cart, checkout_start, purchase]
        at:
          allOf:
            - $ref: '#/components/schemas/Timestamp'
          description: When it happened. Defaults to now; must be within the last 7 days and at most 5 minutes ahead.
        ref:
          $ref: '#/components/schemas/ProductRef'
        locale:
          type: string
          maxLength: 16
          description: Overrides `visitor.locale`.
          example: it
        market:
          type: string
          maxLength: 64
          description: Market or country code.
          example: IT
        channel:
          type: string
          maxLength: 64
          description: Overrides `visitor.channel`. Forced to `web-pixel` when the request comes with `Origin` null.
          example: web
    ProductRef:
      type: object
      description: |
        How the event points at a product. Matched to the Catalog by `variant`, then `sku`, then `product`, then
        `handle`. Each part is a string (integers are accepted) truncated to 100 characters.
      properties:
        product:
          type: string
          maxLength: 100
          description: Shopify product id.
          example: '8123456789'
        variant:
          type: string
          maxLength: 100
          description: Shopify variant id.
          example: '44001234567'
        sku:
          type: string
          maxLength: 100
          example: TEE-BLK-M
        handle:
          type: string
          maxLength: 100
          example: classic-tee-black
    PayloadValue:
      description: A scalar value. Strings ≤ 500 characters; non-finite numbers drop the key.
      type: [string, number, integer, boolean, 'null']
    PageViewEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            kind:
              const: page_view
            payload:
              type: object
              maxProperties: 20
              additionalProperties: false
              properties:
                type:
                  type: string
                  maxLength: 500
                  description: Page type (e.g. `home`, `collection`, `product`, `search`).
                  example: collection
                path:
                  type: string
                  maxLength: 500
                  description: Path without query string (except `q`) or fragment; reserved areas keep their first segment only.
                  example: /collections/men
                collection:
                  type: string
                  maxLength: 500
                  example: men
                filters:
                  type: string
                  maxLength: 500
                  description: Active storefront filters as `key=value;key=value`.
                  example: filter.v.option.color=Black;filter.v.price.lte=80
                sort:
                  type: string
                  maxLength: 500
                  example: price-ascending
    ProductViewEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            kind:
              const: product_view
            payload:
              type: object
              maxProperties: 20
              additionalProperties: false
              properties:
                price:
                  $ref: '#/components/schemas/PayloadValue'
                currency:
                  type: string
                  maxLength: 500
                  example: EUR
                pos:
                  type: integer
                  description: Position of the product in the list it was clicked from.
                  example: 3
                list:
                  type: string
                  maxLength: 500
                  description: The list it was clicked from (`search`, `collection[:handle]`, `recommendation[:id]`).
                  example: collection:men
                from:
                  type: string
                  maxLength: 500
                  description: Same-origin referrer path (normalised like `page_view.path`).
                  example: /collections/men
    SearchEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            kind:
              const: search
            query:
              type: string
              maxLength: 200
              description: The words searched. Truncated to 200 characters; withheld if it looks like personal data.
              example: black hoodie
            payload:
              type: object
              maxProperties: 20
              additionalProperties: false
              properties:
                results:
                  type: integer
                  description: Number of results shown.
                  example: 12
                pos:
                  type: integer
                  example: 1
    CartPayload:
      type: object
      maxProperties: 20
      additionalProperties: false
      properties:
        cart:
          type: string
          maxLength: 500
          description: Cart token (without the `?key=` part).
          example: c1-9f2b7e4a1d
        quantity:
          type: integer
          description: Quantity added or removed. Below 1 no cart line is created; omitted on removal closes all matching lines.
          example: 1
        price:
          $ref: '#/components/schemas/PayloadValue'
        currency:
          type: string
          maxLength: 500
          example: EUR
    AddToCartEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            kind:
              const: add_to_cart
            payload:
              $ref: '#/components/schemas/CartPayload'
    RemoveFromCartEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            kind:
              const: remove_from_cart
            payload:
              $ref: '#/components/schemas/CartPayload'
    CheckoutStartEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            kind:
              const: checkout_start
            payload:
              type: object
              maxProperties: 20
              additionalProperties: false
              properties:
                cart:
                  type: string
                  maxLength: 500
                  example: c1-9f2b7e4a1d
                checkout:
                  type: string
                  maxLength: 500
                  description: Checkout token.
                  example: ck-5e1a9c3b7d
                currency:
                  type: string
                  maxLength: 500
                  example: EUR
                items:
                  type: integer
                  example: 2
    PurchaseEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            kind:
              const: purchase
            payload:
              type: object
              maxProperties: 20
              additionalProperties: false
              properties:
                cart:
                  type: string
                  maxLength: 500
                  example: c1-9f2b7e4a1d
                checkout:
                  type: string
                  maxLength: 500
                  example: ck-5e1a9c3b7d
                order_id:
                  type: integer
                  description: Shopify order id. Used to stitch the cart to the order imported from Shopify.
                  example: 5901234567
                order:
                  type: string
                  maxLength: 500
                  description: Order name.
                  example: ACM-1042
                currency:
                  type: string
                  maxLength: 500
                  example: EUR
                items:
                  type: integer
                  example: 2
    RejectedEvent:
      type: object
      required: [index, reason]
      properties:
        index:
          type: integer
          description: Zero-based position of the event in the batch.
          example: 4
        reason:
          type: string
          enum: [shape, kind, origin, uid, time, storage]
          description: |
            `shape` not an object · `kind` unsupported kind · `origin` kind not allowed with `Origin: null` ·
            `uid` malformed uid · `time` unreadable or out-of-window timestamp · `storage` the database refused the
            batch.
    EventsResult:
      type: object
      description: The batch was processed (or dry-run in test mode).
      required: [accepted, stored, duplicates, rejected, payloadDropped, withheld, skew, unmapped, cartRefused, identified, identityWithheld, visitor]
      properties:
        accepted:
          type: integer
          description: Events that passed validation.
        stored:
          type: integer
          description: Events actually written (always `0` in test mode).
        duplicates:
          type: integer
          description: Events skipped because their `uid` was already stored or repeated in the batch.
        rejected:
          type: array
          items:
            $ref: '#/components/schemas/RejectedEvent'
        payloadDropped:
          type: array
          items:
            type: integer
          description: Indexes of events whose payload (or some of its keys) was dropped.
        withheld:
          type: array
          items:
            type: integer
          description: Indexes of search events whose query was withheld as possible personal data.
        skew:
          type: integer
          description: Seconds added to every `at` to correct the browser clock (`0` when not corrected).
        unmapped:
          type: integer
          description: Stored events whose `ref` matched no product in the Catalog.
        cartRefused:
          type: integer
          description: Events whose cart token belongs to another visitor (event stored, cart untouched). Always `0` in test mode.
        identified:
          type: boolean
          description: The customer reference verified and the visitor was linked to the customer.
        identityWithheld:
          type: boolean
          description: A customer reference was sent but `personalisation` was not granted, so events stay anonymous.
        visitor:
          type: string
          description: The visitor id of the batch.
        test:
          type: boolean
          const: true
          description: Present only in test mode.
        testKnown:
          type: boolean
          description: Present only in test mode — whether the code is the store's active test code.
    EventsNoConsentResult:
      type: object
      description: Consent is not `granted`; nothing was written.
      required: [accepted, stored, reason, rejected, visitor]
      properties:
        accepted:
          type: integer
        stored:
          type: integer
          const: 0
        reason:
          type: string
          const: consent
        rejected:
          type: array
          items:
            $ref: '#/components/schemas/RejectedEvent'
        visitor:
          type: string
        test:
          type: boolean
          const: true
        testKnown:
          type: boolean
    EventsStorageResult:
      type: object
      description: The database refused the batch; every event is listed with reason `storage`.
      required: [accepted, stored, rejected, visitor]
      properties:
        accepted:
          type: integer
        stored:
          type: integer
          const: 0
        rejected:
          type: array
          items:
            $ref: '#/components/schemas/RejectedEvent'
        visitor:
          type: string
        test:
          type: boolean
          const: true
        testKnown:
          type: boolean
    ConsentUpdate:
      type: object
      required: [consent]
      properties:
        key:
          $ref: '#/components/schemas/PublicKey'
        sentAt:
          $ref: '#/components/schemas/Timestamp'
        consent:
          $ref: '#/components/schemas/ConsentState'
        purposes:
          $ref: '#/components/schemas/Purposes'
        consentSource:
          $ref: '#/components/schemas/ConsentSource'
        visitor:
          type: object
          description: '`id` is required for `granted`; optional for `denied`, `revoked` and `pending`.'
          properties:
            id:
              $ref: '#/components/schemas/VisitorId'
            consentSource:
              $ref: '#/components/schemas/ConsentSource'
            purposes:
              $ref: '#/components/schemas/Purposes'
        test:
          $ref: '#/components/schemas/TestCode'
    ConsentResult:
      type: object
      required: [visitor, consent, counted]
      properties:
        visitor:
          type: [string, 'null']
          description: The visitor id, or null when none was sent.
        consent:
          $ref: '#/components/schemas/ConsentState'
        stored:
          type: boolean
          description: Whether a visitor row was written. Absent when `deleted` is present.
        counted:
          type: boolean
          description: Whether the installation's anonymous consent counters moved.
        deleted:
          type: object
          description: Present when a known visitor was erased.
          properties:
            events:
              type: integer
            identities:
              type: integer
            carts:
              type: integer
        test:
          type: boolean
          const: true
        testKnown:
          type: boolean
    InstallationConfig:
      type: object
      required: [ok, store, installation, status, locales, pixelEnabled]
      properties:
        ok:
          type: boolean
          const: true
        store:
          type: string
          description: Store slug.
          example: acme
        installation:
          type: string
          description: Installation name.
          example: Acme EU storefront
        status:
          type: string
          enum: [active]
          description: Always `active` (a paused installation answers `403`).
        locales:
          type: array
          items:
            type: string
          example: [it, en]
        pixelEnabled:
          type: boolean
          description: 'Whether `Origin: null` (the checkout Web Pixel) is accepted.'
    SizeReading:
      type: object
      description: How the product fits for everyone, inferred from size swaps.
      properties:
        direction:
          type: string
          enum: [runs_large, runs_small, as_expected]
        basis:
          type: string
          enum: [product, gender-fit-type, gender-fit]
          description: '`product` this item''s own swaps; otherwise items of the same segment, fit (and type).'
        people:
          type: integer
          description: Shoppers whose swaps back the reading (at least 8 for a product, 40 for a family).
        share:
          type: number
          description: Share of swaps towards the larger size.
        base:
          type: number
          description: The same share for the whole segment, for comparison.
        fit:
          type: [string, 'null']
          description: The product's fit attribute value.
        segment:
          type: string
          description: The product's gender segment (`unknown` when not set).
          example: man
        says:
          type: string
          description: A ready-made English sentence.
    SizeAdvice:
      type: object
      description: The size recommended to the shopper who is looking.
      properties:
        size:
          type: string
          example: M
        usual:
          type: string
          description: The shopper's usual size on this segment and scale.
          example: L
        step:
          type: integer
          enum: [-1, 0, 1]
          description: Steps from the usual size (never more than one).
        segment:
          type: string
          example: man
        scale:
          type: string
          description: Size scale identifier.
          example: alpha
        basis:
          type: string
          enum: [product, gender-fit-type, gender-fit, none]
        people:
          type: integer
        share:
          type: [number, 'null']
        base:
          type: [number, 'null']
        fit:
          type: [string, 'null']
        confident:
          type: boolean
          description: Whether the usual size is settled (an unsettled size is never adjusted).
        kept:
          type: integer
          description: Items kept in the usual size.
        seen:
          type: integer
          description: Items considered.
        reason:
          type: string
          enum: [aligned, step, unsettled, gap, no-larger, no-smaller, no-fit-data]
        says:
          type: string
          description: A ready-made English sentence (third person, for staff or UI copy).
    SizeWidgetResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [product, reading, advice, personalised, why]
          properties:
            product:
              type: object
              properties:
                id:
                  type: integer
                sizes:
                  type: array
                  description: Sizes the product is sold in, in wearing order. Empty for one-size, unpublished or unknown products.
                  items:
                    type: string
            reading:
              oneOf:
                - $ref: '#/components/schemas/SizeReading'
                - type: 'null'
            advice:
              oneOf:
                - $ref: '#/components/schemas/SizeAdvice'
                - type: 'null'
            personalised:
              type: boolean
            why:
              type: [string, 'null']
              enum: [anonymous, unverified, no-consent, no-history, no-sizes, null]
              description: |
                Why the answer is not personal: `anonymous` no customer reference · `unverified` the reference did not
                verify · `no-consent` the visitor has not granted `personalisation` · `no-history` not enough purchase
                history · `no-sizes` the product has no sizes (or is not published). `null` when personalised.
    Message:
      type: object
      required: [message]
      properties:
        message:
          type: string
    CollectorValidationError:
      type: object
      required: [message, errors]
      properties:
        message:
          type: string
        errors:
          type: object
          description: Field path → one message string.
          additionalProperties:
            type: string
    ValidationError:
      type: object
      required: [message, errors]
      properties:
        message:
          type: string
        errors:
          type: object
          additionalProperties:
            type: array
            items:
              type: string
