---
openapi: 3.1.0
info:
  title: Fluid Storefront v2026-04 API
  version: v2026-04
  description: |
    The modernized storefront surface, covering all eight storefront
    resources: **Categories**, **Collections**, **Products**, **Posts**,
    **Pages**, **Media**, **Enrollment Packs**, and **Playlists**.

    ## Two surfaces

    Every resource is exposed twice, and the pair is the thing to
    understand first:

    - **Public** — `/api/v202604/{resource}` and
      `/api/v202604/{resource}/{slug}`. Unauthenticated
      (`security: []`); the store is resolved from the request
      subdomain. Addressed by `slug`, the same identifier that appears
      in `canonical_url`. Returns only live rows — non-live content
      never leaks here.
    - **Company** — `/api/v202604/company/{resource}`. Bearer-token
      authenticated, addressed by `id`, and returns every row whatever
      its lifecycle state, so an admin can manage drafts and archives.
      Full CRUD plus the standardized `shares`, `lighthouse`, and
      `compliance` member actions on each resource.

    Path segments use the storefront's nouns, not the database's:
    `enrollment-packs` and `playlists` (the Library model).

    ## One response shape

    Every resource response opens with the same canonical "common top",
    in the same order, before any resource-specific fields:

        id, slug, title, description, image_url, canonical_url, images,
        active, status, publish_at, seo, metafields, languages

    That means a consumer can render any storefront resource from one
    code path. `seo` is always present and always fully populated — the
    server resolves it through a priority chain (SEO record → resource
    field → store default → built-in), so there is no client-side
    fallback logic and the API can never disagree with the rendered
    `<head>`. `metafields`, `languages`, and (where applicable)
    `countries` are always arrays, never null.

    ## Lifecycle

    `status` is the canonical four-value contract — `draft`,
    `scheduled`, `published`, `archived` — and resolves at the API
    boundary: a stored `scheduled` row whose `publish_at` has passed
    reads as `published` with no write required. `active` is the
    separate merchant on/off toggle; a resource is live only when it is
    both active and resolved-published. Company create and update
    auto-queue Lighthouse and compliance scans, so the scores on the
    member actions always describe the current state of the resource.

    ## Listing and pagination

    Index endpoints share one query surface: `lang`, `q` (Elasticsearch
    over title and description), `filter[…]`, `sort`, and cursor
    pagination via `page[cursor]` / `page[limit]` (default 25, max 100).
    Follow `meta.pagination.next_cursor`; do not construct cursors.
    Products additionally take `country`, which selects the
    country-relative price and currency AND gates availability.

    Categories and Collections expose their products from their own
    path — `GET /api/v202604/categories/{slug}/products` — which is the
    products catalog scoped to one owner, with the same card shape,
    filters, sorting, and pagination.

    ## Translating a resource

    Translations are written inline on the resource's own create/update.
    There is no separate translations endpoint and no `translations`
    object in the payload — the `lang` query parameter selects the locale
    a request reads or writes.

    **Read** a locale with `lang` on any GET:

        GET /api/v202604/company/collections/4821?lang=fr

    Translated fields (`title`, `description`, `image_url`, and each
    resource's own translated fields) come back inline in that locale.
    On the public surface any field with no `fr` translation falls back
    to its `en` value, so a partially-translated resource never renders
    blank. The `languages` array on every response lists exactly the
    locales that carry a translation, always including the default.

    **Write** a locale with `lang` on POST/PATCH:

        PATCH /api/v202604/company/collections/4821?lang=fr
        { "collection": { "title": "Essentiels bien-être" } }

    Omitting `lang` writes the store's default locale — the store's own
    default language, not the platform's. A store selling in French
    treats `lang=fr` as its default write.

    A translation PATCH is TEXT ONLY: against any other locale,
    everything but the translated fields is ignored, so translating a
    title can never regenerate the slug, move `status`, or change
    `country_isos`, metafields, SEO, or associations. Send those in a
    request against the store's default locale instead.

    Creating is different: POST always writes the store's default locale
    whatever `lang` says, and keeps its full payload. A resource created
    under a translation would have no value in the store's own language
    and would take its slug from the translation. `lang` is still
    validated on POST, so an unsupported locale is a 422 rather than a
    surprise.

    A `lang` the store does not sell in returns 422 — translations are
    never written to a locale the store has not enabled. Add the language
    to the store first.

    The typical flow is: create in the default locale, then one PATCH per
    additional locale carrying only the translated text.
  contact:
    email: support@fluid.app
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
servers:
- url: https://api.fluid.app
- url: https://{company}.fluid.app
  description: Production server with company subdomain
  variables:
    company:
      default: myco
      description: Company subdomain
tags:
- name: storefront
  description: |
    Unauthenticated public surface that backs storefront pages and
    AEO crawlers. All operations are `security: []`.
- name: company
  description: |
    Token-authenticated company surface that backs the admin UI and
    integrations.
components:
  securitySchemes:
    bearer_auth:
      type: http
      scheme: bearer
      description: |
        Bearer token authentication. Accepts company-admin tokens with
        `storefront.view` for read operations and `storefront.update`
        for writes and scan triggers (legacy per-resource grants like
        `categories.update` / `products.view` / etc. are still accepted
        during the migration), required on every `company` operation.
        Storefront public operations under `/api/v202604/{resource}` and
        `/api/v202604/{resource}/{slug}` are unauthenticated
        (`security: []`).
  responses:
    Unauthorized:
      description: |
        Missing or invalid bearer token. The body is a bare `{ message }`
        object — not the wrapped `ErrorResponse` envelope used by 404 / 422.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/UnauthorizedError"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/ErrorResponse"
    Forbidden:
      description: |
        Authenticated but lacking the required permission. The body is
        bare — `{ error }` when the storefront permission is missing, or
        `{ message }` when the credential is not company-admin tier — not
        the wrapped `ErrorResponse` envelope used by 404 / 422.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/ForbiddenError"
    UnprocessableEntity:
      description: Validation failed.
      content:
        application/json:
          schema:
            "$ref": "#/components/schemas/ErrorResponse"
  parameters:
    Lang:
      name: lang
      in: query
      required: false
      description: |
        Response locale. ISO code (e.g. `fr`, `de`). When omitted,
        responds in the company's default locale. On the public
        surface, falls back to `en` for any translated field missing
        in the requested locale.
      schema:
        type: string
        example: fr
    LangWrite:
      name: lang
      in: query
      required: false
      description: |
        Locale to WRITE, and to render the response in. ISO code
        (e.g. `fr`, `de`); omit to write the store's default locale.

        On PATCH, a locale other than the store's own default writes ONLY
        the translated fields — slug, lifecycle, countries, metafields,
        SEO, and associations in the same payload are ignored, so
        translating a title cannot regenerate the slug or move `status`.
        Send those in a request against the store's default locale.

        POST always writes the store's default locale, whatever `lang`
        says, and keeps its full payload. Mobility's base values hold the
        default locale, so a resource created under a translation would
        read back blank in the store's own language. `lang` is still
        validated on POST. Translate a resource after it exists, with
        PATCH.

        The comparison is against the STORE's default language, not the
        platform's — a store selling in French treats `lang=fr` as its
        default write, not a translation.

        A locale the store does not sell in returns 422. See
        "Translating a resource" in the API description.
      schema:
        type: string
        example: fr
    PageCursor:
      name: page[cursor]
      in: query
      required: false
      description: |
        Rotulus cursor returned in `meta.pagination.next_cursor` (or
        `prev_cursor`). Omit on the first page.
      schema:
        type: string
    PageLimit:
      name: page[limit]
      in: query
      required: false
      description: |
        Page size. Default 25. Max 100. Requests above 100 return 422.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    FilterCountry:
      name: filter[country]
      in: query
      required: false
      description: |
        ISO-2 country code. Restricts the catalog to resources available
        in that country — combining the `with_country_iso` scope's
        OR-empty semantics: unrestricted resources (no `access_countries`
        rows) match every country.
      schema:
        type: string
        example: GB
    FilterLanguage:
      name: filter[language]
      in: query
      required: false
      description: |
        ISO locale. Restricts the catalog to resources translated in
        that locale.
      schema:
        type: string
        example: fr
    Country:
      name: country
      in: query
      required: false
      description: |
        ISO-2 country code that selects COUNTRY-RELATIVE Product pricing.
        Product-only — Categories and other storefront resources use
        `filter[country]` as an access-list visibility filter instead.

        For Products, `country` drives the `pricing` block and each
        variant's `variant_countries`: it picks which per-country variant
        price and currency are rendered. On the catalog it also drops
        products with no active variant in the requested country. When
        omitted, falls back to the company's default country.
      schema:
        type: string
        example: GB
    Q:
      name: q
      in: query
      required: false
      description: |
        Free-text search. Backed by Searchkick / Elasticsearch over
        translation-aware `title` and `description` fields.
      schema:
        type: string
    Sort:
      name: sort
      in: query
      required: false
      description: |
        Single sort key. Prefix with `-` for descending. Default
        `position` then `id`.
      schema:
        type: string
        enum: [position, -position, title, -title, created_at, -created_at]
    SortNoPosition:
      name: sort
      in: query
      required: false
      description: |
        Single sort key. Prefix with `-` for descending. Default
        `-created_at`. These resources have no manual `position`, so
        the `position` sort key is not offered.
      schema:
        type: string
        enum: [title, -title, created_at, -created_at]
    FilterActive:
      name: filter[active]
      in: query
      required: false
      description: |
        Company-surface only. `"true"` / `"false"` restrict to active /
        inactive rows; a blank value (`filter[active]=`) means "no
        filter" — it is NOT `where(active: nil)`.
      schema:
        type: string
        enum: ["", "true", "false"]
  schemas:
    Meta:
      type: object
      description: |
        Response metadata included on every successful response body.
        `request_id` and `timestamp` are always present; list endpoints
        also add `pagination`.
      required: [request_id, timestamp]
      properties:
        request_id:
          type:
          - string
          - 'null'
        timestamp:
          type: string
          format: date-time
        pagination:
          "$ref": "#/components/schemas/Pagination"
    Envelope:
      type: object
      description: |
        Shared success envelope for list / show / create / update
        responses: the resource payload is returned alongside a top-level
        integer `status` and `meta`. Composed onto each resource response
        via `allOf`.
      required: [status, meta]
      properties:
        status:
          type: integer
          example: 200
        meta:
          "$ref": "#/components/schemas/Meta"
    UnauthorizedError:
      type: object
      description: |
        Bare 401 body — a single `message` string, not the wrapped
        `ErrorResponse`.
      required: [message]
      properties:
        message:
          type: string
          example: Invalid credentials.
    ForbiddenError:
      description: |
        Bare 403 body: `{ error }` when a storefront permission is missing,
        or `{ message }` when the credential is not company-admin tier.
      oneOf:
        - type: object
          required: [error]
          properties:
            error:
              type: string
              example: Not authorized.
        - type: object
          required: [message]
          properties:
            message:
              type: string
              example: Not authorized.
    ErrorResponse:
      type: object
      required:
      - error
      - status
      - meta
      properties:
        error:
          type: object
          required:
          - message
          properties:
            message:
              type: string
            details:
              type: object
              additionalProperties: true
        status:
          type: integer
        meta:
          "$ref": "#/components/schemas/Meta"
    ResponsiveImageSet:
      type: [object, "null"]
      description: |
        Three ImageKit-transformed renditions of the resource's
        primary image: 200/600/1200 wide, scale-down only, format
        auto-negotiated. `null` when the resource has no image.
      properties:
        thumb:
          $ref: "#/components/schemas/ImageRendition"
        medium:
          $ref: "#/components/schemas/ImageRendition"
        large:
          $ref: "#/components/schemas/ImageRendition"
    ImageRendition:
      type: object
      required: [url, width]
      properties:
        url:
          type: string
          example: https://ik.imagekit.io/fluidapp/tr:w-600,c-at_max,f-auto,q-75/categories/4821.png
        width:
          type: integer
          example: 600
    Seo:
      type: object
      description: |
        Render-ready search-engine optimization metadata. ALWAYS present
        on every storefront response — never null. Values are resolved
        server-side via this priority chain:

        - `title`         SEO title → resource title → company name
        - `description`   SEO desc → resource desc → company default → "Live Free."
        - `image_url`     SEO image → resource og_image → resource image → company default → hardcoded
        - `block_crawler` SEO block_crawler (false when no SEO row)

        Frontends, AEO crawlers, and SDKs can render directly from these
        values — no client-side fallback logic, no chance of HTML <head>
        and JSON API drifting. `id` is exposed only on the authenticated
        surface and distinguishes a merchant-configured SEO row from
        inherited defaults.
      required: [title, description, image_url, block_crawler]
      properties:
        title:
          type: string
          example: "Summer Sale — Up to 40% Off"
          description: "Resolved title. Never null."
        description:
          type: string
          example: Shop the July clearance event before it ends.
          description: "Resolved description. Never null."
        image_url:
          type: string
          example: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/9215.png
          description: "Resolved OG-ready 1200×630 image URL. Never null."
        block_crawler:
          type: boolean
          example: false
          description: "When true, the resource page emits a noindex meta tag."
        id:
          type: [integer, "null"]
          example: 9215
          description: "Authenticated-only. SearchEngineOptimizer row id when one exists; null when the values are inherited from the resource / company / hardcoded defaults."
    Metafield:
      type: object
      required: [namespace, key, value, value_type]
      properties:
        namespace:
          type: string
          example: custom
        key:
          type: string
          example: material
        value:
          example: cotton
        value_type:
          type: string
          example: single_line_text_field
        description:
          type: [string, "null"]
          example: The primary material used in this product
        created_at:
          type: string
          format: date-time
          example: "2026-05-01T12:00:00Z"
        updated_at:
          type: string
          format: date-time
          example: "2026-05-01T12:00:00Z"
        locked:
          type: [boolean, "null"]
          example: false
    Pagination:
      type: object
      required: [cursor, limit, prev_cursor, next_cursor]
      properties:
        cursor:
          type: [string, "null"]
        limit:
          type: integer
          example: 25
        prev_cursor:
          type: [string, "null"]
        next_cursor:
          type: [string, "null"]
    CategoryBase:
      type: object
      description: |
        Storefront Category. The first thirteen fields are the canonical
        common top shared by every storefront resource in v202604 — same
        names, same order, same types across Categories, Collections,
        Products, Pages, Enrollment Packs, Posts, Media, Playlists.
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages,
         parent_id, position, source_type, has_children, countries]
      properties:
        id:
          type: integer
          description: |
            Numeric identifier for the category. This is the canonical company-surface
            identifier used to address the row on
            `/api/v202604/company/categories/{id}` endpoints.
          example: 4821
        slug:
          type: string
          description: |
            URL-safe identifier and the canonical public identifier for the category.
            It matches the row's `canonical_url` and is the key used by the public
            show endpoint (`GET /api/v202604/categories/{slug}`).
          example: summer-sale
        title:
          type: string
          description: |
            Human-readable display title of the category. When the slug is
            auto-generated, it derives from this value.
          example: Summer Sale
        description:
          type: [string, "null"]
          description: |
            Optional long-form description of the category; `null` when unset.
          example: Hand-picked categories for the July clearance.
        image_url:
          type: [string, "null"]
          description: |
            URL of the category's primary image, or `null` when it has no image. See
            `images` for responsive renditions.
          example: https://cdn.fluid.app/categories/4821.png
        canonical_url:
          type: string
          description: |
            Absolute canonical storefront URL for the category, built from its current
            `slug`.
          example: https://acme.fluid.app/home/categories/summer-sale
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          description: |
            Merchant on/off toggle. When false, the resource is hidden
            from EVERY public surface regardless of `status`.
          example: true
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            Lifecycle. The auto-go-live rule applies at read time: a
            stored `scheduled` row whose `publish_at` has passed
            resolves to `published`. "Live" = `active == true` AND
            resolved `status == published`.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          description: |
            Scheduled go-live timestamp (ISO 8601), or `null` when none is set. At
            read time, once `publish_at` has passed a stored `scheduled` row resolves
            to `published` automatically. Models go-live only — there is no
            corresponding unpublish-at timestamp.
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          description: |
            Custom metadata entries (each a `namespace`/`key`/`value` with a
            `value_type`) attached to the category; empty array when none are set.
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          description: |
            ISO codes of locales this resource is translated into.
            Always includes the store's default language.
            Always includes the company default locale; never empty.
          example: [en, fr, de]
        parent_id:
          type: [integer, "null"]
          description: |
            The id of the parent category, or `null` when the category is at the root
            (top level). The literal `"root"` is a value of the `filter[parent_id]`
            query parameter, not a value of this field.
          example: null
        position:
          type: integer
          description: |
            The category's sort order among its siblings. The default catalog sort
            (`position` then `id`) uses it.
          example: 1
        source_type:
          type: [string, "null"]
          description: |
            Which kind of resource this Category groups
            (`product` / `medium` / `page` / `library` / `promotion`).
          example: product
        has_children:
          type: boolean
          description: |
            `true` when the category has at least one child category, counted
            across every lifecycle state — a live parent whose only children are
            draft, scheduled, or archived still reports `true`. It is NOT scoped to
            live children, even on the public (live-only) surface. `false` only when
            the category is a true leaf. Use it to decide whether to fetch a level
            deeper when walking the tree.
          example: false
        countries:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. Empty array means
            available in every country the company sells in
            (unrestricted).
          example: [GB, US]
    Category:
      description: |
        Public Category read shape — the storefront (unauthenticated)
        projection. Closed with `unevaluatedProperties: false` so any
        authenticated-only field (notably `custom_slug`) leaking onto the
        public surface is caught in conformance.
      allOf:
        - $ref: "#/components/schemas/CategoryBase"
      unevaluatedProperties: false
    CategoryAuthenticated:
      description: |
        Authenticated (company-surface) Category read shape: the public
        `CategoryBase` plus `custom_slug`, which is admin metadata never
        rendered on the public surface.
      allOf:
        - $ref: "#/components/schemas/CategoryBase"
        - type: object
          properties:
            custom_slug:
              type: boolean
              description: |
                Authenticated-only. True when the merchant has pinned a
                permanent merchant-set slug; false when the slug is
                auto-generated from `title` and regenerates on rename.
                Rendered only on the company surface — admin metadata about
                how the slug is managed, not something a storefront consumer
                needs.
              example: false
      unevaluatedProperties: false
    CategoryListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [categories]
          properties:
            categories:
              type: array
              items:
                $ref: "#/components/schemas/Category"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    CategoryShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [category]
          properties:
            category:
              $ref: "#/components/schemas/Category"
    CategoryCompanyListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [categories]
          properties:
            categories:
              type: array
              items:
                $ref: "#/components/schemas/CategoryAuthenticated"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    CategoryCompanyShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [category]
          properties:
            category:
              $ref: "#/components/schemas/CategoryAuthenticated"
    CategoryWrite:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
          example: Summer Sale
        description:
          type: [string, "null"]
        image_url:
          type: [string, "null"]
        image_path:
          type: [string, "null"]
        slug:
          type: string
          description: |
            Send `slug` + `custom_slug: true` together to pin a
            permanent merchant-set slug. Without `custom_slug: true`,
            the slug auto-regenerates from `title` on every title
            change.
        custom_slug:
          type: boolean
          description: |
            Send together with `slug` and set `true` to pin a permanent merchant-set
            slug. Without it, the slug is auto-generated from `title` and regenerates
            whenever the title changes.
        active:
          type: boolean
          description: |
            Merchant on/off toggle. Set `false` to hide the category from every public
            surface regardless of `status`.
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            Lifecycle state to set. `published` with `active: true` makes the category
            live; `scheduled` with a future `publish_at` goes live automatically when
            that time passes. There is no separate publish endpoint — visibility is
            changed through `active` and `status`.
        publish_at:
          type: [string, "null"]
          format: date-time
          description: |
            Target go-live timestamp used with `status: scheduled`. Once it passes,
            the row resolves to `published` at read time. Models go-live only — there
            is no unpublish-at field.
        parent_id:
          type: [integer, "null"]
          description: |
            Set to the parent category's id to nest this category; `null` (or omitted
            on create) makes it top-level. On update, set another id to re-parent it,
            or `null` to promote it to the top level.
        position:
          type: integer
          description: |
            Sort order among sibling categories at the same level.
        source_type:
          type: string
          description: |
            Which kind of resource this category groups: `product`, `medium`, `page`,
            `library`, or `promotion`.
        application_theme_template_id:
          type: [integer, "null"]
        country_isos:
          type: array
          items:
            type: string
          description: |
            ISO country codes restricting availability; maps to the read-side
            `countries` field. An empty array (`[]`) clears all restrictions
            (unrestricted — available everywhere the company sells); omitting the key
            leaves the existing countries unchanged (PATCH semantics).
        product_ids:
          type: array
          items:
            type: integer
          description: |
            Full replacement set of product ids in this category. `[]`
            empties it; key omitted leaves membership unchanged. Ids must
            belong to the authenticated store — a product from another
            store returns 422.
        metafields_attributes:
          type: array
          description: |
            Metafields to write on the category using Rails nested-attributes
            semantics: each entry sets `namespace`, `key`, `value`, and `value_type`,
            with an optional `description`; send `_destroy: true` to remove an entry.
            Category write bodies do NOT accept a per-metafield `id` — category
            metafields are created or replaced, never updated in place by id.
            (`CollectionWrite.metafields_attributes` does accept an `id`; this
            asymmetry is intentional and matches the backend write contract.)
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              namespace:
                type: string
              key:
                type: string
              value: {}
              value_type:
                type: string
              description:
                type: string
              _destroy:
                type: boolean
        search_engine_optimizer_attributes:
          type: [object, "null"]
          description: |
            SEO record nested attributes. The response key is renamed to
            `seo` for v202604, but the request key stays at the AR
            `accepts_nested_attributes_for` name during the migration.
          properties:
            id:
              type: integer
            title:
              type: string
            description:
              type: string
            image_url:
              type: string
            block_crawler:
              type: boolean
    CategoryCreateRequest:
      type: object
      required: [category]
      additionalProperties: false
      properties:
        category:
          allOf:
            - $ref: "#/components/schemas/CategoryWrite"
            - required: [title]
    CategoryUpdateRequest:
      type: object
      required: [category]
      additionalProperties: false
      properties:
        category:
          $ref: "#/components/schemas/CategoryWrite"
    PostBase:
      type: object
      description: |
        Storefront Post — the resource that backs the blog. The first
        thirteen fields are the canonical common top shared by every
        storefront resource in v202604. Post adds an editorial tail
        (`summary`, `post_date`, `post_author`) plus `category_id` and
        `collection_ids`.

        **Notable deviations from the canonical defaults:**
        - `status` defaults to `"published"` (the column default and the
          v202604 API default), not the canonical `"draft"`. Posts have
          historically defaulted to live; v202604 honors that.
        - The canonical `publish_at` field maps to the underlying
          `scheduled_at` column on the Post model. The API surface is
          `publish_at` everywhere (request + response) — clients never
          see `scheduled_at`.
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages, countries,
         summary, post_date, post_author, category_id, collection_ids]
      properties:
        id:
          type: integer
          example: 7305
        slug:
          type: string
          example: how-we-shipped-the-summer-line
        title:
          type: string
          example: How We Shipped the Summer Line
        description:
          type: [string, "null"]
          example: A behind-the-scenes look at three months of work.
        image_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/posts/7305.png
        canonical_url:
          type: string
          example: https://acme.fluid.app/home/blog/how-we-shipped-the-summer-line
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          description: |
            Merchant on/off toggle. When false, the Post is hidden from
            EVERY public surface regardless of `status`.
          example: true
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            Lifecycle. Auto-go-live applies: a stored `scheduled` row
            whose `publish_at` has passed resolves to `published` at
            read time. Default `"published"` (Post deviation — see
            schema description).
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          description: |
            Scheduled go-live timestamp. Maps to the underlying
            `scheduled_at` column on the Post model; the v202604 API
            surface uses `publish_at` everywhere for consistency with
            the other storefront resources.
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          description: |
            ISO codes of locales this Post is translated into. Always
            includes the company default locale; never empty.
          example: [en, fr]
        countries:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. Empty array means
            available in every country the company sells in
            (unrestricted).
          example: [GB, US]
        summary:
          type: [string, "null"]
          description: Editorial lead paragraph shown on blog listing cards.
          example: A short lead paragraph shown on listing cards.
        post_date:
          type: [string, "null"]
          format: date
          description: Original publication date for display. Distinct from `publish_at`.
          example: "2026-05-01"
        post_author:
          type: [string, "null"]
          description: Author byline. Free-form string; not a User association.
          example: Dwight Schrute
        category_id:
          type: [integer, "null"]
          description: Optional Category this post is filed under.
          example: 4821
        collection_ids:
          type: array
          items:
            type: integer
          description: Collections this post appears in.
          example: [5102, 5103]
    Post:
      description: |
        Public Post read shape — the storefront (unauthenticated)
        projection. Closed with `unevaluatedProperties: false` so any
        authenticated-only field (notably `custom_slug`) leaking onto the
        public surface is caught in conformance.
      allOf:
        - $ref: "#/components/schemas/PostBase"
      unevaluatedProperties: false
    PostAuthenticated:
      description: |
        Authenticated (company-surface) Post read shape: the public
        `PostBase` plus `custom_slug`, admin metadata never rendered on the
        public surface.
      allOf:
        - $ref: "#/components/schemas/PostBase"
        - type: object
          properties:
            custom_slug:
              type: boolean
              description: |
                Authenticated-only. True when the merchant has pinned a
                permanent merchant-set slug; false when the slug is
                auto-generated from `title` and regenerates on rename.
                Rendered only on the company surface.
              example: false
      unevaluatedProperties: false
    PostListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [posts]
          properties:
            posts:
              type: array
              items:
                $ref: "#/components/schemas/Post"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    PostShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [post]
          properties:
            post:
              $ref: "#/components/schemas/Post"
    PostCompanyListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [posts]
          properties:
            posts:
              type: array
              items:
                $ref: "#/components/schemas/PostAuthenticated"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    PostCompanyShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [post]
          properties:
            post:
              $ref: "#/components/schemas/PostAuthenticated"
    PostWrite:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
          example: How We Shipped the Summer Line
        description:
          type: [string, "null"]
        image_url:
          type: [string, "null"]
        slug:
          type: string
          description: |
            Send `slug` + `custom_slug: true` together to pin a
            permanent merchant-set slug.
        custom_slug:
          type: boolean
        active:
          type: boolean
        status:
          type: string
          enum: [draft, scheduled, published, archived]
        publish_at:
          type: [string, "null"]
          format: date-time
          description: |
            Maps to the underlying `scheduled_at` column. A `scheduled`
            row whose `publish_at` has passed resolves to `published`
            at the boundary (auto-go-live) — no second write needed.
        application_theme_template_id:
          type: [integer, "null"]
        country_isos:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. `[]` clears
            restrictions; key omitted leaves countries unchanged.
        summary:
          type: [string, "null"]
        post_date:
          type: [string, "null"]
          format: date
        post_author:
          type: [string, "null"]
        category_id:
          type: [integer, "null"]
        collection_ids:
          type: array
          items:
            type: integer
        metafields_attributes:
          type: array
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              namespace:
                type: string
              key:
                type: string
              value: {}
              value_type:
                type: string
              description:
                type: string
              _destroy:
                type: boolean
        search_engine_optimizer_attributes:
          type: [object, "null"]
          properties:
            id:
              type: integer
            title:
              type: string
            description:
              type: string
            image_url:
              type: string
            block_crawler:
              type: boolean
    PostCreateRequest:
      type: object
      required: [post]
      additionalProperties: false
      properties:
        post:
          allOf:
            - $ref: "#/components/schemas/PostWrite"
            - required: [title]
    PostUpdateRequest:
      type: object
      required: [post]
      additionalProperties: false
      properties:
        post:
          $ref: "#/components/schemas/PostWrite"
    ProductBase:
      type: object
      description: |
        Storefront Product. The first twelve fields are the canonical
        common top shared by every storefront resource in v202604 — same
        names, same order, same types across Categories, Collections,
        Products, Pages, Enrollment Packs, Posts, Media, Playlists.

        Product is the foundation's lone variant: it carries the shared
        common top but DELIBERATELY OMITS the `countries` array — a
        Product's geographic availability is variant-level, not a single
        access list. Instead the `country` query parameter drives the
        `pricing` block: it selects which variant's per-country price and
        currency are rendered. Product price is therefore COUNTRY-RELATIVE,
        not absolute (see the `pricing` field and the `country` parameter).

        Everything below `languages` is the Product-specific commerce tail:
        `sku`, `is_bundle`, `in_stock`, `has_subscription_plans`,
        `pricing`, `default_variant`, `variants`, `subscription_plans`,
        `media`, and `bundle_groups`.
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages,
         sku, is_bundle, in_stock, has_subscription_plans, pricing,
         default_variant, variants, subscription_plans, media, bundle_groups]
      properties:
        id:
          type: integer
          description: Unique identifier for the product.
          example: 60311
        slug:
          type: [string, "null"]
          description: |
            URL-safe identifier, unique within the store; the public show
            resolves by this. Null for legacy products created without a
            slug (the column is nullable and friendly_id only backfills on
            write).
          example: scrute-farms-beet-blend
        title:
          type: string
          description: Human-readable product name shown in the storefront.
          example: Schrute Farms Beet Blend
        description:
          type: [string, "null"]
          description: Longer-form product copy. Null when the merchant set none. (Show payload only.)
          example: A cold-pressed beet concentrate from the finest acres on Schrute Farms.
        image_url:
          type: [string, "null"]
          description: Absolute URL of the product's primary image. Null when none. (Show payload only.)
          example: https://cdn.fluid.app/products/60311.png
        canonical_url:
          type: string
          description: Absolute canonical storefront URL for the product (the `/home/products/:slug` path).
          example: https://acme.fluid.app/home/products/scrute-farms-beet-blend
        images:
          description: Responsive ImageKit renditions (thumb/medium/large) of the primary image; null when none.
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          description: |
            Derived merchant on/off toggle: `true` when the product is not
            archived. When `false`, the product is hidden from EVERY public
            surface regardless of `status`. Unlike Category, Product has no
            standalone `active` column — it is computed from the `status`
            enum.
          example: true
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            Lifecycle, mapped at read time from the Product's integer
            `status` enum (`active`/`draft`/`archived`) plus `publish_at`.
            The auto-go-live rule applies: an `active` product with a future
            `publish_at` resolves to `scheduled`; once `publish_at` passes
            (or is null) it resolves to `published`. "Live" = `active == true`
            AND resolved `status == published`.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          description: |
            ISO codes of locales this product is translated into. Always
            includes the company default locale; never empty.
          example: [en, fr]
        sku:
          type: [string, "null"]
          description: |
            The product-level stock-keeping unit. Null when the merchant
            tracks SKUs per variant only.
          example: SCRUTE-BEET-001
        is_bundle:
          type: boolean
          description: |
            True when this product is a bundle assembled from bundle groups;
            its price is the configured bundle price, not a variant price.
          example: false
        in_stock:
          type: boolean
          description: |
            True when at least one variant is purchasable for some country.
            Mirrors the merchandising stock flag.
          example: true
        has_subscription_plans:
          type: boolean
          description: |
            True when the product has at least one active subscription plan,
            so the storefront can surface subscribe-and-save options.
          example: true
        pricing:
          type: [object, "null"]
          description: |
            COUNTRY-RELATIVE retail pricing for the requested `country`
            (defaults to the company's default country). The `country`
            query parameter selects which variant's per-country price and
            currency are rendered. Always the consumer-facing RETAIL price —
            never wholesale. `null` when the product has no resolvable price
            in any country. Bundle products price via the configured bundle
            price for the country.
          required: [price, currency_code, compare_at, subscription_price, display_price]
          properties:
            price:
              type: [string, "null"]
              description: Numeric retail unit price in the country's currency, serialized as a decimal string.
              example: "29.99"
            currency_code:
              type: [string, "null"]
              description: ISO 4217 currency code for the resolved country.
              example: USD
            compare_at:
              type: [string, "null"]
              description: |
                Strike-through "compare at" price when the merchant set one,
                serialized as a decimal string; null otherwise. Always null
                for bundle products.
              example: "39.99"
            subscription_price:
              type: [string, "null"]
              description: |
                Per-cycle subscription price when the product has a
                subscription plan, serialized as a decimal string; null
                otherwise. Always null for bundle products.
              example: "26.99"
            display_price:
              type: [string, "null"]
              description: |
                Pre-formatted, currency-symbol price string ready to render
                in the storefront.
              example: "$29.99"
        default_variant:
          type: [object, "null"]
          description: |
            Lightweight master-variant summary (id + sku). The full variant
            payload is in `variants`. Null when the product has no master
            variant.
          required: [id, sku]
          properties:
            id:
              type: integer
              example: 880912
            sku:
              type: [string, "null"]
              example: SCRUTE-BEET-001-12OZ
        variants:
          type: array
          description: |
            Every variant ordered by position, each rendered at the retail
            pricing tier. Per-country prices are keyed by ISO under
            `variant_countries`.
          items:
            $ref: "#/components/schemas/ProductVariant"
        subscription_plans:
          type: array
          description: |
            Active subscription plans available for this product, each
            wrapped with its default/active join metadata. The nested
            `subscription_plan` is priced against the requested country's
            variant at the retail tier.
          items:
            type: object
            required: [id, default, active, subscription_plan]
            properties:
              id:
                type: integer
                description: The product_subscription_plan join-row id.
                example: 7741
              default:
                type: boolean
                description: True when this is the product's default plan.
                example: true
              active:
                type: boolean
                description: True when the plan is active for this product.
                example: true
              subscription_plan:
                $ref: "#/components/schemas/SubscriptionPlan"
        media:
          type: array
          description: |
            Media attached to the product (videos, documents) ordered by
            priority.
          items:
            $ref: "#/components/schemas/ProductMedium"
        bundle_groups:
          type: array
          description: |
            Bundle groups defining the selectable items for a bundle
            product. Empty for non-bundle products.
          items:
            $ref: "#/components/schemas/ProductBundleGroup"
    Product:
      description: |
        Public Product read shape — the storefront (unauthenticated)
        projection. Closed with `unevaluatedProperties: false` so any
        authenticated-only field (notably `custom_slug`) leaking onto the
        public surface is caught in conformance.
      allOf:
        - $ref: "#/components/schemas/ProductBase"
      unevaluatedProperties: false
    ProductAuthenticated:
      description: |
        Authenticated (company-surface) Product read shape: the public
        `ProductBase` plus admin metadata never rendered on the public
        surface: `custom_slug` and, on detail responses, the ordered
        `gallery` needed to verify product image writes.
      allOf:
        - $ref: "#/components/schemas/ProductBase"
        - type: object
          required: [gallery]
          properties:
            custom_slug:
              type: boolean
              description: |
                Authenticated-only. True when the merchant has pinned a
                permanent merchant-set slug; false when the slug is
                auto-generated from `title` and regenerates on rename.
                Rendered only on the company surface.
              example: false
            gallery:
              type: array
              description: |
                Authenticated-detail-only product gallery, ordered by
                `position`. Includes DAM identity when an image URL matches
                a DAM asset variant, allowing company clients to read back
                and reconcile `images_attributes` writes.
              items:
                $ref: "#/components/schemas/ProductGalleryImage"
      unevaluatedProperties: false

    ProductGalleryImage:
      type: object
      additionalProperties: false
      required: [id, image_url, image_path, position, dam_asset_code]
      properties:
        id:
          type: integer
          description: Unique identifier for the product image.
          example: 73011
        image_url:
          type: [string, "null"]
          description: Localized absolute URL of the gallery image.
          example: https://ik.imagekit.io/fluid/products/60311/front.png
        image_path:
          type: [string, "null"]
          description: Stored path for the gallery image, when available.
          example: products/60311/front.png
        position:
          type: [integer, "null"]
          description: Display order; lower positions appear first.
          example: 1
        dam_asset_code:
          type: [string, "null"]
          description: Code of the matching DAM asset, or null when the image is not DAM-backed.
          example: BEET-FRONT

    ProductVariant:
      type: object
      description: |
        A single purchasable Product variant, rendered at the retail pricing
        tier. Per-country retail pricing lives under `variant_countries`, keyed
        by ISO-2 country code. (The v202604 storefront surface is retail-only;
        wholesale and preferred-customer fields are not exposed here.)
      properties:
        id:
          type: integer
          description: Unique identifier for the variant.
          example: 880912
        sku:
          type: [string, "null"]
          description: Stock-keeping unit for this variant. Null when the variant has no SKU.
          example: SCRUTE-BEET-001-12OZ
        is_master:
          type: boolean
          description: >-
            True when this is the product's master (default) variant — the one
            used for single-variant products and as the fallback selection.
          example: true
        title:
          type: [string, "null"]
          description: >-
            Short variant name, typically the option combination (e.g. "12 oz").
            Null for a product with no distinct variant options.
          example: 12 oz
        display_name:
          type: [string, "null"]
          description: >-
            Full human-readable name combining the product title and this
            variant's options, ready to show in a cart or order line.
          example: Schrute Farms Beet Blend — 12 oz
        image_url:
          type: [string, "null"]
          description: >-
            Absolute URL of the variant's own primary image. Null when the
            variant inherits the product-level image.
          example: https://cdn.fluid.app/variants/880912.png
        position:
          type: [integer, "null"]
          description: Sort order of this variant within the product. Null when unset.
          example: 1
        subscription_only:
          type: boolean
          description: >-
            True when this variant can ONLY be purchased as a subscription —
            one-time (single) purchase is not offered.
          example: false
        shipping_included_in_price:
          type: boolean
          description: >-
            True when shipping is already bundled into the price, so no separate
            shipping charge is applied at checkout.
          example: false
        track_quantity:
          type: boolean
          description: >-
            True when inventory is tracked for this variant (stock counts are
            enforced). When false, the variant is always purchasable.
          example: true
        inventory_quantity:
          type: [integer, "null"]
          description: >-
            Total sellable units on hand across all warehouses. Null when
            `track_quantity` is false (inventory is not counted).
          example: 142
        bar_code:
          type: [string, "null"]
          description: Barcode / UPC / EAN for the variant. Null when unset.
          example: "0079827230015"
        keep_selling:
          type: boolean
          description: >-
            True when the variant stays purchasable after stock reaches zero
            (overselling/back-order allowed); false stops sales at zero stock.
          example: false
        limit_subscription:
          type: boolean
          description: >-
            True when the quantity per subscription order is capped (see
            `subscription_max_qty`).
          example: false
        subscription_max_qty:
          type: [integer, "null"]
          description: >-
            Maximum quantity allowed on a single subscription order when
            `limit_subscription` is true. Null when uncapped.
          example: null
        limit_quantity_per_customer:
          type: boolean
          description: >-
            True when a per-customer purchase limit applies to this variant
            (see `customer_limit`).
          example: false
        customer_limit:
          type: [integer, "null"]
          description: >-
            Maximum quantity a single customer may purchase when
            `limit_quantity_per_customer` is true. Null when unlimited.
          example: null
        option_attrs:
          type: array
          description: >-
            Human-readable option values that define this variant (e.g. size,
            flavor), in option order.
          items:
            type: string
          example: ["12 oz"]
        option_ids:
          type: array
          description: IDs of the option values that define this variant (parallel to `option_attrs`).
          items:
            type: integer
          example: [4471]
        allow_subscription:
          type: boolean
          description: True when at least one active subscription plan is available for this variant.
          example: true
        images:
          type: array
          description: The variant's images, ordered by `position`.
          items:
            type: object
            properties:
              id:
                type: integer
                description: Image identifier.
                example: 5521
              image_url:
                type: string
                description: Absolute, render-ready URL of the image.
                example: https://cdn.fluid.app/variants/880912/front.png
              image_path:
                type: [string, "null"]
                description: Raw storage path behind `image_url`, for clients applying their own ImageKit transforms.
                example: variants/880912/front.png
              position:
                type: integer
                description: Sort order of the image within the variant.
                example: 1
        inventory_levels:
          type: array
          description: Per-warehouse stock breakdown for this variant.
          items:
            type: object
            properties:
              id:
                type: integer
                description: Inventory-level row identifier.
                example: 33019
              available:
                type: integer
                description: >-
                  Units sellable right now — `on_hand` minus `committed` minus
                  `unavailable`. This is the number a storefront should treat as
                  buyable.
                example: 142
              committed:
                type: integer
                description: Units reserved by open (placed but unfulfilled) orders.
                example: 3
              on_hand:
                type: integer
                description: Physical units present in the warehouse, regardless of reservation.
                example: 145
              unavailable:
                type: integer
                description: >-
                  On-hand units that cannot be sold (e.g. damaged, on quality
                  hold, or otherwise reserved out of sale).
                example: 0
              warehouse:
                type: object
                description: The warehouse this stock level belongs to.
                properties:
                  id:
                    type: integer
                    description: Warehouse identifier.
                    example: 12
                  name:
                    type: string
                    description: Human-readable warehouse name.
                    example: Scranton DC
                  active:
                    type: boolean
                    description: True when the warehouse is actively fulfilling orders.
                    example: true
                  country_iso:
                    type: string
                    description: ISO-2 country code the warehouse ships from.
                    example: US
        variant_countries:
          type: object
          description: |
            Per-country RETAIL pricing keyed by ISO-2 country code. Each entry
            carries the consumer-facing price, currency, volumes, and display
            strings for that country — this is where the COUNTRY-RELATIVE pricing
            lives. The top-level `pricing` block is the resolved view for the
            single requested `country`. Wholesale and preferred-customer values
            are not exposed on this surface.
          additionalProperties:
            type: object
            properties:
              id:
                type: integer
                description: VariantCountry row identifier.
                example: 451233
              active:
                type: boolean
                description: True when this variant is available for sale in this country.
                example: true
              country_id:
                type: integer
                description: Internal identifier of the country.
                example: 233
              country_name:
                type: string
                description: Human-readable country name.
                example: United States
              currency_code:
                type: string
                description: ISO 4217 currency code the prices in this entry are denominated in.
                example: USD
              price:
                type: string
                description: Retail unit price in this country's currency, serialized as a decimal string.
                example: "29.99"
              compare_price:
                type: [string, "null"]
                description: >-
                  Strike-through "compare at" reference price when the merchant
                  set one, serialized as a decimal string; null otherwise.
                example: "39.99"
              subscription_price:
                type: [string, "null"]
                description: >-
                  Per-cycle retail price when bought on subscription, serialized
                  as a decimal string; null when the variant has no subscription
                  pricing.
                example: "26.99"
              cv:
                type: string
                format: decimal
                description: >-
                  Commissionable Volume (CV) — the volume credited for this item
                  that counts toward commission calculations.
                example: "20"
              qv:
                type: string
                format: decimal
                description: >-
                  Qualifying Volume (QV) — the volume credited for this item that
                  counts toward rank and qualification requirements.
                example: "20"
              points:
                type: integer
                description: >-
                  Points the buyer accumulates on an order for purchasing this
                  item (per unit).
                example: 100
              shipping:
                type: string
                description: Flat per-unit shipping cost for this country, in the entry's currency, serialized as a decimal string.
                example: "4.99"
              shipping_time:
                type: [string, "null"]
                description: Human-readable estimated delivery window for this country. Null when unset.
                example: 3–5 business days
              display_price:
                type: string
                description: Pre-formatted, currency-symbol price string ready to render.
                example: "$29.99"
              display_subscription_price:
                type: string
                description: Pre-formatted subscription price string ready to render.
                example: "$26.99"
              buyable:
                type: boolean
                description: >-
                  True when a shopper can purchase this variant in this country
                  right now (active and, if tracked, in stock).
                example: true
              subscription_plans:
                type: array
                description: >-
                  Retail subscription pricing for this country, one entry per
                  active plan on the product.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                      description: Subscription plan identifier.
                      example: 4410
                    subscription_price_in_currency:
                      type: string
                      description: Pre-formatted per-cycle subscription price for this plan.
                      example: "26.99"
                    subscribe_and_save_in_currency:
                      type: string
                      description: Pre-formatted amount saved per cycle vs. the one-time price.
                      example: "3.00"
                    subscribe_and_save_for_display:
                      type: string
                      description: Localized "subscribe and save" marketing string.
                      example: Subscribe and save $3.00
        metafields:
          type: array
          description: Custom metafields attached to the variant.
          items:
            type: object
            properties:
              namespace:
                type: string
                description: Grouping namespace for the metafield.
                example: custom
              key:
                type: string
                description: Metafield key, unique within its namespace.
                example: harvest_acre
              value:
                description: The metafield value (type per `value_type`).
                example: North Forty
              value_type:
                type: string
                description: The metafield's data type.
                example: single_line_text_field

    SubscriptionPlan:
      type: object
      description: |
        A subscription plan, rendered in the product_subscription_plan view
        (priced against the requested country's variant at the retail tier).
      properties:
        id:
          type: integer
          example: 4410
        name:
          type: string
          example: Monthly Beet Club
        billing_interval:
          type: integer
          example: 1
        billing_interval_unit:
          type: string
          enum: [day, week, month, year]
          example: month
        billing_frequency_in_words:
          type: string
          example: Every month
        volume_interval:
          type: integer
          example: 1
        volume_interval_unit:
          type: string
          enum: [day, week, month, year]
          example: month
        allow_max_billing_cycles:
          type: boolean
          example: false
        max_billing_cycles:
          type: [integer, "null"]
          example: null
        price_adjustment_type:
          type: string
          example: percentage
        price_adjustment_amount:
          type: [string, "null"]
          example: "10.0"
        savings_display_mode:
          type: string
          example: percent
        active:
          type: boolean
          example: true
        subscription_price:
          type: [string, "null"]
          description: |
            Pre-formatted per-cycle subscription price for the requested
            country's variant at the retail tier.
          example: "$26.99"
        savings_label:
          type: [string, "null"]
          example: Subscribe & save 10%

    ProductMedium:
      type: object
      description: A media item (video, document) attached to the product.
      properties:
        id:
          type: integer
          example: 99021
        slug:
          type: string
          example: how-to-press-beets
        title:
          type: string
          example: How To Press Beets
        kind:
          type: [string, "null"]
          example: video
        media_type:
          type: [string, "null"]
          example: video
        media_format:
          type: [string, "null"]
          example: mp4
        image_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/media/99021/poster.png
        video_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/media/99021/clip.mp4
        canonical_url:
          type: string
          example: https://acme.fluid.app/home/media/how-to-press-beets
        description:
          type: [string, "null"]
          example: A two-minute walkthrough of the cold-press process.
        duration:
          type: [integer, "null"]
          example: 124
        active:
          type: boolean
          example: true

    ProductBundleGroup:
      type: object
      description: |
        A bundle group defining selectable items for a bundle product.
        Present only on bundle products.
      properties:
        id:
          type: integer
          example: 3301
        title:
          type: string
          example: Pick Your Beets
        description:
          type: [string, "null"]
          example: Choose any three jars for your bundle.
        group_type:
          type: [string, "null"]
          example: selectable
        min_selections:
          type: [integer, "null"]
          example: 1
        max_selections:
          type: [integer, "null"]
          example: 3
        selection_type:
          type: [string, "null"]
          example: multiple
        allow_subscriptions:
          type: boolean
          example: true
        force_subscriptions:
          type: boolean
          example: false
        sort_order:
          type: [integer, "null"]
          example: 1
        bundle_group_items:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
                example: 71002
              bundled_variant_id:
                type: [integer, "null"]
                example: 880912
              quantity:
                type: [integer, "null"]
                example: 1

    ProductCard:
      type: object
      description: |
        The LEAN catalog card returned by the Products index (and the AEO
        `.json` catalog). It is the default ProductBlueprint view: the
        canonical common top MINUS the heavy SEO-ish fields the card
        deliberately omits (`description`, `image_url`, `seo`) plus the
        commerce SUMMARY (`sku`, `is_bundle`, `in_stock`,
        `has_subscription_plans`, `pricing`, `default_variant`). The full
        `description` / `image_url` / `seo` and the heavy commerce tail
        (`variants`, `subscription_plans`, `media`, `bundle_groups`) are
        reserved for the single-record show payload (see `Product`), keeping
        the catalog N+1-free regardless of page size.
      required:
        [id, slug, title, canonical_url, images, active, status, publish_at,
         metafields, languages, sku, is_bundle, in_stock,
         has_subscription_plans, pricing, default_variant]
      properties:
        id:
          type: integer
          example: 60311
        slug:
          type: [string, "null"]
          description: Null for legacy products created without a slug (the column is nullable).
          example: scrute-farms-beet-blend
        title:
          type: string
          example: Schrute Farms Beet Blend
        canonical_url:
          type: string
          example: https://acme.fluid.app/home/products/scrute-farms-beet-blend
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          description: |
            Derived merchant on/off toggle: `true` when the product is not
            archived. When `false`, the product is hidden from EVERY public
            surface regardless of `status`.
          example: true
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            Lifecycle, mapped at read time from the Product's integer
            `status` enum plus `publish_at`. "Live" = `active == true` AND
            resolved `status == published`.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          example: null
        metafields:
          type: array
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          description: |
            ISO codes of locales this product is translated into. Always
            includes the company default locale; never empty.
          example: [en, fr]
        sku:
          type: [string, "null"]
          description: |
            The product-level stock-keeping unit. Null when the merchant
            tracks SKUs per variant only.
          example: SCRUTE-BEET-001
        is_bundle:
          type: boolean
          description: |
            True when this product is a bundle assembled from bundle groups;
            its price is the configured bundle price, not a variant price.
          example: false
        in_stock:
          type: boolean
          description: |
            True when at least one variant is purchasable for some country.
            Mirrors the merchandising stock flag.
          example: true
        has_subscription_plans:
          type: boolean
          description: |
            True when the product has at least one active subscription plan,
            so the storefront can surface subscribe-and-save options.
          example: true
        pricing:
          type: [object, "null"]
          description: |
            COUNTRY-RELATIVE retail pricing for the requested `country`
            (defaults to the company's default country). Always the
            consumer-facing RETAIL price — never wholesale. `null` when the
            product has no resolvable price in any country.
          required: [price, currency_code, compare_at, subscription_price, display_price]
          properties:
            price:
              type: [string, "null"]
              description: Numeric retail unit price in the country's currency, serialized as a decimal string.
              example: "29.99"
            currency_code:
              type: [string, "null"]
              description: ISO 4217 currency code for the resolved country.
              example: USD
            compare_at:
              type: [string, "null"]
              description: |
                Strike-through "compare at" price when the merchant set one,
                serialized as a decimal string; null otherwise. Always null
                for bundle products.
              example: "39.99"
            subscription_price:
              type: [string, "null"]
              description: |
                Per-cycle subscription price when the product has a
                subscription plan, serialized as a decimal string; null
                otherwise. Always null for bundle products.
              example: "26.99"
            display_price:
              type: [string, "null"]
              description: |
                Pre-formatted, currency-symbol price string ready to render
                in the storefront.
              example: "$29.99"
        default_variant:
          type: [object, "null"]
          description: |
            Lightweight master-variant summary (id + sku) for the catalog
            card. The full variant payload is on the show (`Product`) view.
            Null when the product has no master variant.
          required: [id, sku]
          properties:
            id:
              type: integer
              example: 880912
            sku:
              type: [string, "null"]
              example: SCRUTE-BEET-001-12OZ

    ProductListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [products]
          properties:
            products:
              type: array
              items:
                $ref: "#/components/schemas/ProductCard"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]

    ProductShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [product]
          properties:
            product:
              $ref: "#/components/schemas/Product"
    ProductCompanyShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [product]
          properties:
            product:
              $ref: "#/components/schemas/ProductAuthenticated"

    ProductWrite:
      type: object
      additionalProperties: false
      description: |
        Company write contract for a Product. Keeps the ActiveRecord
        `accepts_nested_attributes_for` key names (`*_attributes`) so the
        existing Retail::ProductManager / VariantManager write orchestration
        consumes the payload unchanged. `status` is the RAW Product enum key
        (`active`/`draft`/`archived`) — the canonical published/scheduled
        split is presentation-only on read. NOTE: dynamic bundle-group writes
        (`product_bundle_groups_attributes`) are NOT accepted on this
        surface; static bundle-component links (`product_bundles_attributes`)
        ARE.
      properties:
        title:
          type: string
          example: Schrute Farms Beet Blend
        description:
          type: [string, "null"]
          example: A cold-pressed beet concentrate from the finest acres on Schrute Farms.
        introduction:
          type: [string, "null"]
          example: Bred for sugar content, pressed within the hour.
        feature_text:
          type: [string, "null"]
          example: 100% single-origin beets.
        image_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/products/60311.png
        sku:
          type: [string, "null"]
          example: SCRUTE-BEET-001
        slug:
          type: string
          description: |
            Send `slug` + `custom_slug: true` together to pin a permanent
            merchant-set slug. Without `custom_slug: true`, the slug
            auto-regenerates from `title` on every title change.
          example: scrute-farms-beet-blend
        custom_slug:
          type: boolean
          example: true
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            The canonical storefront vocabulary, unified across every
            resource. `published` and `scheduled` map to the stored `active`
            enum value (`scheduled` requires a future `publish_at`; an
            explicit `published` with no publish_at clears a future
            schedule); `draft` and `archived` store as-is. The raw enum key
            `active` is still accepted for back-compat but is undocumented.
          example: published
        public:
          type: boolean
          example: true
        publish_at:
          type: [string, "null"]
          format: date-time
          example: "2026-07-01T08:00:00Z"
        commission:
          type: [number, "null"]
          example: 12.5
        bundle:
          type: boolean
          example: false
        track_inventory_on_bundle_items:
          type: boolean
          example: false
        show_reviews:
          type: boolean
          example: true
        no_index:
          type: boolean
          example: false
        tax_category_id:
          type: [integer, "null"]
          example: 14
        international_tax_type:
          type: [string, "null"]
          example: standard
        category_id:
          type: [integer, "null"]
          example: 4821
        label_id:
          type: [integer, "null"]
          example: 91
        collection_ids:
          type: array
          items:
            type: integer
          example: [501, 502]
        tag_ids:
          type: array
          items:
            type: integer
          example: [12, 13]
        option_attrs:
          type: array
          items:
            type: string
          example: [Size]
        application_theme_template_id:
          type: [integer, "null"]
          example: 77
        publish_to_portal_shop:
          type: boolean
          example: true
        publish_to_retail_store:
          type: boolean
          example: true
        publish_to_mobile_store:
          type: boolean
          example: true
        publish_to_share_tab:
          type: boolean
          example: false
        metadata:
          type: object
          additionalProperties: true
          example:
            origin: Schrute Farms
        dam_asset_code:
          type: [string, "null"]
          example: BEET-12OZ
        dam_asset_id:
          type: [string, "null"]
          example: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
        metafields_attributes:
          type: array
          description: Product-level embedded metafields.
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              id:
                type: integer
                description: Present = update / destroy an existing entry.
              namespace:
                type: string
                example: custom
              key:
                type: string
                example: harvest_acre
              value:
                example: North Forty
              value_type:
                type: string
                example: single_line_text_field
              description:
                type: string
                example: Which acre the beets came from.
              _destroy:
                type: boolean
        search_engine_optimizer_attributes:
          type: [object, "null"]
          description: |
            SEO record nested attributes. The response key is renamed to
            `seo` for v202604, but the request key stays at the AR
            `accepts_nested_attributes_for` name during the migration.
          properties:
            id:
              type: integer
            title:
              type: string
              example: Schrute Farms Beet Blend — Cold Pressed
            description:
              type: string
              example: Single-origin beet concentrate, pressed within the hour.
            image_url:
              type: string
            block_crawler:
              type: boolean
              example: false
        images_attributes:
          type: array
          description: |
            Product-level gallery images. Authenticated detail responses
            return the persisted, position-ordered result under `gallery`.
          items:
            type: object
            required: [position, image_url]
            properties:
              id:
                type: integer
              position:
                type: integer
                example: 1
              image_url:
                type: string
                example: https://cdn.fluid.app/products/60311/front.png
              _destroy:
                type: boolean
        product_subscription_plans_attributes:
          type: array
          description: Subscription-plan join rows.
          items:
            type: object
            properties:
              id:
                type: integer
              subscription_plan_id:
                type: integer
                example: 4410
              default:
                type: boolean
                example: true
              active:
                type: boolean
                example: true
              _destroy:
                type: boolean
        product_bundles_attributes:
          type: array
          description: |
            Static-bundle component links (a fixed set of bundled variants).
            Dynamic bundle-group composition is NOT writable on this surface.
          items:
            type: object
            properties:
              id:
                type: integer
              bundled_variant_id:
                type: integer
                example: 880912
              quantity:
                type: integer
                example: 2
              display_externally:
                type: boolean
                example: true
              _destroy:
                type: boolean
        variants_attributes:
          type: array
          description: |
            The deep nested commerce write. Each variant carries its
            per-country pricing (`variant_countries_attributes`), inventory
            levels, images, and metafields. On CREATE a variant MUST be
            priced in at least one country (`variant_countries_attributes`
            required); on PATCH that key is optional.
          items:
            type: object
            properties:
              id:
                type: integer
              option_attrs:
                type: array
                items:
                  type: string
                example: ["12 oz"]
              sku:
                type: string
                example: SCRUTE-BEET-001-12OZ
              is_master:
                type: boolean
                example: true
              title:
                type: string
                example: 12 oz
              display_name:
                type: string
                example: Schrute Farms Beet Blend — 12 oz
              position:
                type: integer
                minimum: 0
                example: 1
              track_quantity:
                type: boolean
                example: true
              bar_code:
                type: string
                example: "0079827230015"
              limit_subscription:
                type: boolean
                example: false
              subscription_max_qty:
                type: integer
                example: 6
              customer_limit:
                type: integer
                example: 12
              keep_selling:
                type: boolean
                example: false
              weight:
                type: string
                example: "0.75"
              unit_of_weight:
                type: string
                example: lb
              _destroy:
                type: boolean
              variant_countries_attributes:
                type: array
                description: |
                  Per-country pricing. REQUIRED on create (at least one
                  country); OPTIONAL on update.
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    active:
                      type: boolean
                      example: true
                    country_id:
                      type: integer
                      example: 233
                    price:
                      type: string
                      format: decimal
                      description: |
                        Retail unit price in the country's currency,
                        serialized as a decimal string. Coerced via the
                        dry-schema `:decimal` contract. NOT nullable — unlike
                        the optional adjuncts below, a null here would be
                        coerced to 0.0 and silently make the variant free, so
                        it is rejected. REQUIRED on create. Optional on
                        update — omit the key on update to leave the
                        existing price unchanged.
                      example: "29.99"
                    subscription_price:
                      type: [string, "null"]
                      format: decimal
                      description: |
                        Recurring subscription unit price, serialized as a
                        decimal string (e.g. "26.99"). Nullable: null stores
                        0.0. On create an omitted key stores 0.0 too; on
                        update it leaves the existing value unchanged.
                      example: "26.99"
                    wholesale:
                      type: [string, "null"]
                      format: decimal
                      description: |
                        Wholesale unit price, serialized as a decimal string
                        (e.g. "18.0"). Nullable: null stores 0.0. On create an
                        omitted key stores 0.0 too; on update it leaves the
                        existing value unchanged.
                      example: "18.0"
                    wholesale_subscription_price:
                      type: [string, "null"]
                      format: decimal
                      description: |
                        Wholesale subscription unit price, serialized as a
                        decimal string (e.g. "16.0"). Nullable: null stores
                        0.0. On create an omitted key stores 0.0 too; on
                        update it leaves the existing value unchanged.
                      example: "16.0"
                    compare_price:
                      type: [string, "null"]
                      format: decimal
                      description: |
                        Compare-at (strikethrough) price, serialized as a
                        decimal string (e.g. "39.99"). Nullable, because
                        source catalogs commonly carry no compare-at price:
                        null stores 0.0. On create an omitted key stores 0.0
                        too; on update it leaves the existing value unchanged.
                      example: "39.99"
                    cv:
                      type: [number, string, "null"]
                      format: decimal
                      example: 20
                      description: >-
                        Commissionable Volume (CV) — the volume credited for this
                        item that counts toward commission calculations. Nullable:
                        null stores 0. On create an omitted key stores 0 too; on
                        update it leaves the existing value unchanged.
                    qv:
                      type: [number, string, "null"]
                      format: decimal
                      example: 20
                      description: >-
                        Qualifying Volume (QV) — the volume credited for this item
                        that counts toward rank and qualification requirements.
                        Nullable: null stores 0. On create an omitted key stores
                        0 too; on update it leaves the existing value unchanged.
                    points:
                      type: integer
                      example: 100
                      description: >-
                        Points the buyer accumulates on an order for purchasing
                        this item (per unit). NOT nullable — the column is NOT
                        NULL; omit the key to accept the default of 0.
                    cost_of_goods_sold:
                      type: [string, "null"]
                      format: decimal
                      description: |
                        Cost of goods sold per unit, serialized as a decimal
                        string (e.g. "9.5"). Nullable: null stores 0.0. On
                        create an omitted key stores 0.0 too; on update it
                        leaves the existing value unchanged.
                      example: "9.5"
                    shipping:
                      type: [string, "null"]
                      format: decimal
                      description: |
                        Per-unit shipping charge, serialized as a decimal
                        string (e.g. "4.99"). Nullable: null stores 0.0. On
                        create an omitted key stores 0.0 too; on update it
                        leaves the existing value unchanged.
                      example: "4.99"
                    tax:
                      type: [string, "null"]
                      format: decimal
                      description: |
                        Per-unit tax amount, serialized as a decimal string
                        (e.g. "2.4"). Nullable: null stores 0.0. On create an
                        omitted key stores 0.0 too; on update it leaves the
                        existing value unchanged.
                      example: "2.4"
                    _destroy:
                      type: boolean
              images_attributes:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    position:
                      type: integer
                      example: 1
                    image_url:
                      type: string
                      example: https://cdn.fluid.app/variants/880912/front.png
                    _destroy:
                      type: boolean
              inventory_levels_attributes:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    warehouse_id:
                      type: integer
                      example: 12
                    available:
                      type: integer
                      example: 142
                    committed:
                      type: integer
                      example: 3
                    on_hand:
                      type: integer
                      example: 145
                    unavailable:
                      type: integer
                      example: 0
              metafields_attributes:
                type: array
                items:
                  type: object
                  required: [namespace, key, value, value_type]
                  properties:
                    id:
                      type: integer
                    namespace:
                      type: string
                      example: custom
                    key:
                      type: string
                      example: harvest_acre
                    value:
                      example: North Forty
                    value_type:
                      type: string
                      example: single_line_text_field
                    _destroy:
                      type: boolean

    ProductCreateRequest:
      type: object
      required: [product]
      additionalProperties: false
      properties:
        product:
          allOf:
            - $ref: "#/components/schemas/ProductWrite"
            - required: [title]

    ProductUpdateRequest:
      type: object
      required: [product]
      additionalProperties: false
      properties:
        product:
          $ref: "#/components/schemas/ProductWrite"
    EnrollmentPack:
      type: object
      description: |
        Storefront Enrollment Pack. The first thirteen fields are the
        canonical common top shared by every storefront resource in v202604.
        Enrollment Packs are schedulable but have no archived state: `status`
        derives from the `active` toggle + `publish_at` (active + future
        publish_at → `scheduled`; active + null/past publish_at →
        `published`; inactive → `draft`). The membership / subscription products
        and agreements (`member_enrollable_products`,
        `subscription_enrollable_products`, `enrollment_pack_agreements`)
        are returned only on the SHOW response, not the catalog card.
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages,
         enrollment_fee, additional_volume, membership_after_one_month,
         membership_optional, countries]
      properties:
        id:
          type: integer
          example: 60311
        slug:
          type: string
          example: primex-welcome-pack
        title:
          type: string
          example: Primex Welcome Pack
        description:
          # KNOWN ISSUE (CURRENT-2505 finding): EnrollmentPack#description is
          # action_text-backed (`translates :description, backend: :action_text`),
          # and the shared Api::V202604::Storefront::Common blueprint renders the
          # raw ActionText::RichText record instead of its plain text — so a pack
          # WITH a description serializes `description` as an object
          # ({ id, body, record_type, ... }), and one without it as null. The same
          # shared bug affects Product and Medium (both action_text-backed). This
          # type reflects that reality; revert to [string, "null"] once Common
          # renders action_text descriptions as plain text.
          type: [string, object, "null"]
          example: Everything a new Primex member needs to get started.
        image_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/enrollment_packs/60311.png
        canonical_url:
          type: string
          example: https://acme.fluid.app/home/enrollments/primex-welcome-pack
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          example: true
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Lifecycle, derived from `active` + `publish_at`. `published` =
            active and live now; `scheduled` = active with a future
            `publish_at` (auto-goes-live when it passes); `draft` =
            inactive. Enrollment Packs have no archived state. "Live" =
            `active == true` AND resolved `status == published`.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          description: When a scheduled pack goes live. null = no schedule.
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          example: [en, fr]
        enrollment_fee:
          type: [string, "null"]
          description: |
            One-time enrollment fee in the company's base currency, or null.
            Serialized as a decimal string (e.g. `"29.0"`), not a JSON number.
          example: "29.0"
        additional_volume:
          type: [integer, "null"]
          description: Bonus volume (CV) granted by enrolling through this pack.
          example: 100
        membership_after_one_month:
          type: boolean
          example: false
        membership_optional:
          type: boolean
          example: true
        countries:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. Empty array means available
            in every country the company sells in (unrestricted).
          example: [GB, US]
        member_enrollable_products:
          type: array
          description: SHOW only. Membership products bundled in the pack.
          items:
            type: object
            properties:
              id: { type: integer }
              product_id: { type: integer }
              variant_id: { type: [integer, "null"] }
        subscription_enrollable_products:
          type: array
          description: SHOW only. Subscription products bundled in the pack.
          items:
            type: object
            properties:
              id: { type: integer }
              product_id: { type: integer }
              variant_id: { type: [integer, "null"] }
              subscription_plan_id: { type: [integer, "null"] }
        enrollment_pack_agreements:
          type: array
          description: SHOW only. Agreements a buyer must accept, in display order.
          items:
            type: object
            properties:
              id: { type: integer }
              agreement_id: { type: integer }
              required: { type: boolean }
              position: { type: integer }
    EnrollmentPackListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [enrollment_packs]
          properties:
            enrollment_packs:
              type: array
              items:
                $ref: "#/components/schemas/EnrollmentPack"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    EnrollmentPackShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [enrollment_pack]
          properties:
            enrollment_pack:
              $ref: "#/components/schemas/EnrollmentPack"
    EnrollmentPackWrite:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
          example: Primex Welcome Pack
        description:
          type: [string, "null"]
        active:
          type: boolean
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Maps back to the `active` toggle + `publish_at`. `draft` turns
            the toggle off; `scheduled` / `published` turn it on (supply a
            future `publish_at` for `scheduled`; an explicit `published`
            with no publish_at clears a future schedule).
        publish_at:
          type: [string, "null"]
          format: date-time
        enrollment_fee:
          type: [number, "null"]
        additional_volume:
          type: [integer, "null"]
        membership_after_one_month:
          type: boolean
        membership_optional:
          type: boolean
        application_theme_template_id:
          type: [integer, "null"]
        country_isos:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. `[]` clears restrictions;
            key omitted leaves countries unchanged. An ISO the store does
            not sell in returns a 422.
        member_enrollable_products_attributes:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              variant_id: { type: integer }
              _destroy: { type: boolean }
        subscription_enrollable_products_attributes:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              variant_id: { type: integer }
              subscription_plan_id: { type: integer }
              _destroy: { type: boolean }
        images_attributes:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              image_url: { type: string }
              position: { type: integer }
              _destroy: { type: boolean }
        enrollment_pack_agreements:
          type: array
          items:
            type: object
            properties:
              id: { type: integer }
              agreement_id: { type: integer }
              required: { type: boolean }
              position: { type: integer }
              _destroy: { type: boolean }
        metafields_attributes:
          type: array
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              namespace: { type: string }
              key: { type: string }
              value: {}
              value_type: { type: string }
              description: { type: string }
              _destroy: { type: boolean }
        search_engine_optimizer_attributes:
          type: [object, "null"]
          properties:
            id: { type: integer }
            title: { type: string }
            description: { type: string }
            image_url: { type: string }
            block_crawler: { type: boolean }
    EnrollmentPackCreateRequest:
      type: object
      required: [enrollment_pack]
      additionalProperties: false
      properties:
        enrollment_pack:
          allOf:
            - $ref: "#/components/schemas/EnrollmentPackWrite"
            - required: [title]
    EnrollmentPackUpdateRequest:
      type: object
      required: [enrollment_pack]
      additionalProperties: false
      properties:
        enrollment_pack:
          $ref: "#/components/schemas/EnrollmentPackWrite"
    MediumBase:
      type: object
      description: |
        Storefront Medium ("media"). The first thirteen fields are the
        canonical common top shared by every storefront resource in v202604.
        Media has no stored status column and no archived state: `status` is
        derived from `active` + `publish_at` (active + future `publish_at` →
        `scheduled`; active + null/past `publish_at` → `published`; inactive →
        `draft`). `visibility` (`open` / `restricted`) is Media's OWN access
        concept and is exposed as a separate field — it is NOT part of the
        active/status lifecycle. "Live" = `active == true` AND resolved
        `status == published`.
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages,
         media_type, kind, media_format, visibility, video_url, pdf_url,
         powerpoint_url, duration, countries]
      properties:
        id:
          type: integer
          example: 60311
        slug:
          type: string
          example: primex-wellness-overview
        title:
          type: [string, "null"]
          description: |
            Human-readable name. Nullable for Media only — legacy rows predate
            the presence validation; new writes always carry a title.
          example: Primex Wellness Overview
        description:
          type: [string, "null"]
          example: A short overview of the Primex wellness range.
        image_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/media/60311.png
        canonical_url:
          type: string
          example: https://acme.fluid.app/home/media/primex-wellness-overview
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          example: true
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Lifecycle, derived from `active` + `publish_at`. `published` =
            active and live now; `scheduled` = active with a future
            `publish_at`; `draft` = inactive. Media has no archived state.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          description: When a scheduled medium goes live. null = no schedule.
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          example: [en, fr]
        media_type:
          type: string
          description: |
            Editorial classification. Canonical values are `share`,
            `training`, `update`, `upload` (the write schema enforces these);
            a small number of legacy rows carry retired values (e.g.
            `lesson`), so readers must tolerate other strings.
          example: share
        kind:
          type: [string, "null"]
          description: Asset kind (video / image / pdf / ppt / link).
          example: video
        media_format:
          type: [string, "null"]
          description: Resolved file format derived from the populated asset URL.
          example: mp4
        visibility:
          type: [string, "null"]
          enum: [open, restricted, null]
          description: |
            Access scope, independent of the lifecycle: `open` (anyone) or
            `restricted` (gated). Not part of active/status. Nullable —
            most legacy rows have no explicit value and behave as `open`
            (the public catalog includes them).
          example: open
        video_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/media/60311.mp4
        pdf_url:
          type: [string, "null"]
          example: null
        powerpoint_url:
          type: [string, "null"]
          example: null
        duration:
          type: [integer, "null"]
          description: Video duration in seconds; 0 (or null) for non-video media.
          example: 312
        countries:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. Empty array means available
            in every country the company sells in (unrestricted).
          example: [GB, US]
    Medium:
      description: |
        Public Medium read shape — the storefront (unauthenticated)
        projection. Closed with `unevaluatedProperties: false` so any
        authenticated-only field (notably `custom_slug`) leaking onto the
        public surface is caught in conformance.
      allOf:
        - $ref: "#/components/schemas/MediumBase"
      unevaluatedProperties: false
    MediumAuthenticated:
      description: |
        Authenticated (company-surface) Medium read shape: the public
        `MediumBase` plus `custom_slug`, admin metadata never rendered on the
        public surface.
      allOf:
        - $ref: "#/components/schemas/MediumBase"
        - type: object
          properties:
            custom_slug:
              type: boolean
              description: |
                Authenticated-only. True when the merchant has pinned a
                permanent merchant-set slug; false when the slug is
                auto-generated from `title` and regenerates on rename.
                Rendered only on the company surface — admin metadata about
                how the slug is managed, not something a storefront consumer
                needs.
              example: false
      unevaluatedProperties: false
    MediumListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [media]
          properties:
            media:
              type: array
              items:
                $ref: "#/components/schemas/Medium"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    MediumShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [medium]
          properties:
            medium:
              $ref: "#/components/schemas/Medium"
    MediumCompanyListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [media]
          properties:
            media:
              type: array
              items:
                $ref: "#/components/schemas/MediumAuthenticated"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    MediumCompanyShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [medium]
          properties:
            medium:
              $ref: "#/components/schemas/MediumAuthenticated"
    MediumWrite:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
          example: Primex Wellness Overview
        description:
          type: [string, "null"]
        active:
          type: boolean
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Maps back to `active` + `publish_at`. `draft` sets active=false;
            `scheduled` / `published` set active=true (supply a future
            `publish_at` for `scheduled`).
        publish_at:
          type: [string, "null"]
          format: date-time
        media_type:
          type: string
          enum: [share, training, update, upload]
        kind:
          type: [string, "null"]
        visibility:
          type: string
          enum: [open, restricted]
        duration:
          type: [integer, "null"]
        image_url:
          type: [string, "null"]
        video_url:
          type: [string, "null"]
        pdf_url:
          type: [string, "null"]
        powerpoint_url:
          type: [string, "null"]
        application_theme_template_id:
          type: [integer, "null"]
        country_isos:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. `[]` clears restrictions;
            key omitted leaves countries unchanged.
        metafields_attributes:
          type: array
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              namespace: { type: string }
              key: { type: string }
              value: {}
              value_type: { type: string }
              description: { type: string }
              _destroy: { type: boolean }
        search_engine_optimizer_attributes:
          type: [object, "null"]
          properties:
            id: { type: integer }
            title: { type: string }
            description: { type: string }
            image_url: { type: string }
            block_crawler: { type: boolean }
    MediumCreateRequest:
      type: object
      required: [medium]
      additionalProperties: false
      properties:
        medium:
          allOf:
            - $ref: "#/components/schemas/MediumWrite"
            - required: [title]
    MediumUpdateRequest:
      type: object
      required: [medium]
      additionalProperties: false
      properties:
        medium:
          $ref: "#/components/schemas/MediumWrite"
    PageBase:
      type: object
      description: |
        Storefront Page — a METADATA surface. The page's rendered content
        lives on the themed storefront page (`canonical_url`), not in the
        API. The first thirteen fields are the canonical common top shared
        by every storefront resource in v202604. Page has no stored status
        column and no archived state: `status` is derived from the
        `publish` toggle + `publish_at` (published toggle + future
        `publish_at` → `scheduled`; toggle + null/past `publish_at` →
        `published`; toggle off → `draft`). "Live" = `active == true` AND
        resolved `status == published`.
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages,
         countries]
      properties:
        id:
          type: integer
          example: 7412
        slug:
          type: string
          example: our-story
        title:
          type: string
          example: Our Story
        description:
          type: [string, "null"]
          description: |
            Longer-form copy describing the page. New in v202604 (the column
            was added for the storefront contract) — existing pages return
            null until edited. Translatable via `lang`.
          example: How Primex went from a Provo garage to a global wellness brand.
        image_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/pages/7412.png
        canonical_url:
          type: string
          example: https://acme.fluid.app/home/pages/our-story
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          description: The merchant publish toggle (stored as `publish`).
          example: true
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Lifecycle, derived from the publish toggle + `publish_at`.
            `published` = toggled on and live now; `scheduled` = toggled on
            with a future `publish_at`; `draft` = toggled off. Page has no
            archived state.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          description: When a scheduled page goes live. null = no schedule.
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          example: [en, fr]
        countries:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. Empty array means available
            in every country the company sells in (unrestricted).
          example: [GB, US]
    Page:
      description: |
        Public Page read shape — the storefront (unauthenticated)
        projection. Closed with `unevaluatedProperties: false` so any
        authenticated-only field (notably `custom_slug`) leaking onto the
        public surface is caught in conformance.
      allOf:
        - $ref: "#/components/schemas/PageBase"
      unevaluatedProperties: false
    PageAuthenticated:
      description: |
        Authenticated (company-surface) Page read shape: the public
        `PageBase` plus `custom_slug`, admin metadata never rendered on the
        public surface.
      allOf:
        - $ref: "#/components/schemas/PageBase"
        - type: object
          properties:
            custom_slug:
              type: boolean
              description: |
                Authenticated-only. True when the merchant has pinned a
                permanent merchant-set slug; false when the slug is
                auto-generated from `title` and regenerates on rename.
                Rendered only on the company surface — admin metadata about
                how the slug is managed, not something a storefront consumer
                needs.
              example: false
            application_theme_template_id:
              type: [integer, "null"]
              description: |
                COMPANY show only (not the index card — it resolves through
                the company's current theme per record). Theme template this
                page renders through. Company-surface-only, so it lives here
                rather than on the public `PageBase`.
              example: null
      unevaluatedProperties: false
    PageListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [pages]
          properties:
            pages:
              type: array
              items:
                $ref: "#/components/schemas/Page"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    PageShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [page]
          properties:
            page:
              $ref: "#/components/schemas/Page"
    PageCompanyListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [pages]
          properties:
            pages:
              type: array
              items:
                $ref: "#/components/schemas/PageAuthenticated"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    PageCompanyShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [page]
          properties:
            page:
              $ref: "#/components/schemas/PageAuthenticated"
    PageWrite:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
          example: Our Story
        description:
          type: [string, "null"]
          example: How Primex went from a Provo garage to a global wellness brand.
        slug:
          type: string
          description: |
            Send together with `custom_slug: true` to pin a permanent
            merchant-set slug; sent alone it persists only until the title
            next changes.
          example: our-story
        custom_slug:
          type: boolean
        active:
          type: boolean
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Maps back to the `publish` toggle + `publish_at`. `draft` turns
            the toggle off; `scheduled` / `published` turn it on (supply a
            future `publish_at` for `scheduled`).
        publish_at:
          type: [string, "null"]
          format: date-time
        image_url:
          type: [string, "null"]
        image_path:
          type: [string, "null"]
        application_theme_template_id:
          type: [integer, "null"]
        application_theme_template_attributes:
          type: [object, "null"]
          description: |
            Nested theme-template attributes, preserved from the legacy
            surface. Passed through to the template model, which validates
            its own shape.
        country_isos:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. `[]` clears restrictions;
            key omitted leaves countries unchanged.
        metafields_attributes:
          type: array
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              namespace: { type: string }
              key: { type: string }
              value: {}
              value_type: { type: string }
              description: { type: string }
              _destroy: { type: boolean }
        search_engine_optimizer_attributes:
          type: [object, "null"]
          description: |
            The ONLY meta-write path on v202604 — the legacy
            `open_graph_attributes` is not carried forward (the OG record is
            a strict subset of the SEO record).
          properties:
            id: { type: integer }
            title: { type: string }
            description: { type: string }
            image_url: { type: string }
            block_crawler: { type: boolean }
    PageCreateRequest:
      type: object
      required: [page]
      additionalProperties: false
      properties:
        page:
          allOf:
            - $ref: "#/components/schemas/PageWrite"
            - required: [title]
    PageUpdateRequest:
      type: object
      required: [page]
      additionalProperties: false
      properties:
        page:
          $ref: "#/components/schemas/PageWrite"
    PlaylistBase:
      type: object
      description: |
        Storefront Playlist (backed by the Library model). The first thirteen
        fields are the canonical common top shared by every storefront resource
        in v202604. Playlists are schedulable but have no archived state:
        `status` derives from the `active` toggle + `publish_at` (active +
        future publish_at → `scheduled`). `total_items_count` and the
        `library_items` array are
        returned only on the SHOW response (`:detail` view), not the catalog
        card, so neither is in the schema-level required set.
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages,
         kind, countries]
      properties:
        id:
          type: integer
          example: 3812
        slug:
          type: string
          example: primex-wellness-playlist
        title:
          type: string
          example: Primex Wellness Playlist
        description:
          type: [string, "null"]
          example: A curated collection of wellness resources for Primex members.
        image_url:
          type: [string, "null"]
          example: https://cdn.fluid.app/libraries/3812.png
        canonical_url:
          type: string
          example: https://acme.fluid.app/home/libraries/primex-wellness-playlist
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          example: true
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Lifecycle, derived from `active` + `publish_at`. `published` =
            active and live now; `scheduled` = active with a future
            `publish_at` (auto-goes-live when it passes); `draft` =
            inactive. Playlists have no archived state. "Live" =
            `active == true` AND resolved `status == published`.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          description: When a scheduled playlist goes live. null = no schedule.
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          example: [en, fr]
        kind:
          type: string
          enum: [manual, auto]
          description: Curation mode. `manual` = hand-picked items, `auto` = rule-based.
          example: manual
        total_items_count:
          type: integer
          description: Number of items currently in the playlist.
          example: 5
        countries:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. Empty array means available
            in every country the company sells in (unrestricted).
          example: [GB, US]
        library_items:
          type: array
          description: SHOW only. Ordered items in this playlist (up to 50).
          items:
            type: object
            properties:
              id: { type: integer }
              relateable_type: { type: string }
              relateable_id: { type: integer }
              order: { type: integer }
    Playlist:
      description: |
        Public Playlist read shape — the storefront (unauthenticated)
        projection. Closed with `unevaluatedProperties: false` so any
        authenticated-only field (notably `custom_slug`) leaking onto the
        public surface is caught in conformance.
      allOf:
        - $ref: "#/components/schemas/PlaylistBase"
      unevaluatedProperties: false
    PlaylistAuthenticated:
      description: |
        Authenticated (company-surface) Playlist read shape: the public
        `PlaylistBase` plus `custom_slug`, admin metadata never rendered on the
        public surface.
      allOf:
        - $ref: "#/components/schemas/PlaylistBase"
        - type: object
          properties:
            custom_slug:
              type: boolean
              description: |
                Authenticated-only. True when the merchant has pinned a
                permanent merchant-set slug; false when the slug is
                auto-generated from `title` and regenerates on rename.
                Rendered only on the company surface — admin metadata about
                how the slug is managed, not something a storefront consumer
                needs.
              example: false
      unevaluatedProperties: false
    PlaylistListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [playlists]
          properties:
            playlists:
              type: array
              items:
                $ref: "#/components/schemas/Playlist"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    PlaylistShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [playlist]
          properties:
            playlist:
              $ref: "#/components/schemas/Playlist"
    PlaylistCompanyListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [playlists]
          properties:
            playlists:
              type: array
              items:
                $ref: "#/components/schemas/PlaylistAuthenticated"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    PlaylistCompanyShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [playlist]
          properties:
            playlist:
              $ref: "#/components/schemas/PlaylistAuthenticated"
    PlaylistWrite:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
          example: Primex Wellness Playlist
        description:
          type: [string, "null"]
        image_url:
          type: [string, "null"]
        slug:
          type: string
          description: |
            URL-safe identifier. Send together with `custom_slug: true` to pin
            a permanent slug. Without `custom_slug: true` the slug regenerates
            on title change.
        custom_slug:
          type: boolean
        active:
          type: boolean
        status:
          type: string
          enum: [draft, scheduled, published]
          description: |
            Maps back to the `active` toggle + `publish_at`. `draft` turns
            the toggle off; `scheduled` / `published` turn it on (supply a
            future `publish_at` for `scheduled`; an explicit `published`
            with no publish_at clears a future schedule).
        publish_at:
          type: [string, "null"]
          format: date-time
        kind:
          type: string
          enum: [manual, auto]
        application_theme_template_id:
          type: [integer, "null"]
        country_isos:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. `[]` clears restrictions;
            key omitted leaves countries unchanged. An ISO the store does
            not sell in returns a 422.
        library_items_attributes:
          type: array
          description: |
            Playlist contents. `relateable_type` is any storefront resource
            a playlist can hold, and the referenced record must belong to the
            authenticated store — an item from another store returns 422.
            `order` is the 1-based position in the playlist; omit it (or send
            `0`) to add the item at the top.
          items:
            type: object
            required: [relateable_id, relateable_type]
            properties:
              id:
                type: integer
              relateable_id:
                type: integer
              relateable_type:
                type: string
                enum: [Product, Medium, Promotion, Page, EnrollmentPack, Category, Collection, Post]
              order:
                type: integer
              _destroy:
                type: boolean
        metafields_attributes:
          type: array
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              namespace: { type: string }
              key: { type: string }
              value: {}
              value_type: { type: string }
              description: { type: string }
              _destroy: { type: boolean }
        search_engine_optimizer_attributes:
          type: [object, "null"]
          properties:
            id: { type: integer }
            title: { type: string }
            description: { type: string }
            image_url: { type: string }
            block_crawler: { type: boolean }
    PlaylistCreateRequest:
      type: object
      description: |
        The wrapper key is `library` (the underlying ActiveRecord model
        name), while the URL resource is `playlists` — this mismatch is
        intentional, not a typo. The write payload nests under `library`;
        with `additionalProperties: false` a `{ playlist: ... }` body is
        rejected.
      required: [library]
      additionalProperties: false
      properties:
        library:
          allOf:
            - $ref: "#/components/schemas/PlaylistWrite"
            - required: [title]
    PlaylistUpdateRequest:
      type: object
      description: |
        The wrapper key is `library` (the underlying ActiveRecord model
        name), while the URL resource is `playlists` — this mismatch is
        intentional, not a typo. The write payload nests under `library`;
        with `additionalProperties: false` a `{ playlist: ... }` body is
        rejected.
      required: [library]
      additionalProperties: false
      properties:
        library:
          $ref: "#/components/schemas/PlaylistWrite"
    CollectionBase:
      type: object
      description: |
        Storefront Collection. The first thirteen fields are the canonical
        common top shared by every storefront resource in v202604 — same
        names, same order, same types across Categories, Collections,
        Products, Pages, Enrollment Packs, Posts, Media, Playlists.
        Collection adds NO public tail fields (admin metadata like
        `position`, `is_default`, and the legacy `public` flag are kept
        out of the v202604 boundary).
      required:
        [id, slug, title, description, image_url, canonical_url, images,
         active, status, publish_at, seo, metafields, languages, countries]
      properties:
        id:
          type: integer
          description: |
            Numeric identifier for the collection. This is the canonical
            company-surface identifier used to address the row on
            `/api/v202604/company/collections/{id}` endpoints.
          example: 5102
        slug:
          type: string
          description: |
            URL-safe identifier and the canonical public identifier for the
            collection. It matches the row's `canonical_url` and is the key used by
            the public show endpoint (`GET /api/v202604/collections/{slug}`).
          example: wellness-essentials
        title:
          type: string
          description: |
            Human-readable display title of the collection. When the slug is
            auto-generated, it derives from this value.
          example: Wellness Essentials
        description:
          type: [string, "null"]
          description: |
            Optional long-form description of the collection; `null` when unset.
          example: Hand-picked supplements and recovery gear.
        image_url:
          type: [string, "null"]
          description: |
            URL of the collection's primary image, or `null` when it has no image. See
            `images` for responsive renditions.
          example: https://cdn.fluid.app/collections/5102.png
        canonical_url:
          type: string
          description: |
            Absolute canonical storefront URL for the collection, built from its
            current `slug`.
          example: https://acme.fluid.app/home/collections/wellness-essentials
        images:
          $ref: "#/components/schemas/ResponsiveImageSet"
        active:
          type: boolean
          description: |
            Merchant on/off toggle. When false, the Collection is hidden
            from EVERY public surface regardless of `status`.
          example: true
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            Lifecycle. The auto-go-live rule applies at read time: a
            stored `scheduled` row whose `publish_at` has passed
            resolves to `published`. "Live" = `active == true` AND
            resolved `status == published`.
          example: published
        publish_at:
          type: [string, "null"]
          format: date-time
          description: |
            Scheduled go-live timestamp (ISO 8601), or `null` when none is set. At
            read time, once `publish_at` has passed a stored `scheduled` row resolves
            to `published` automatically. Models go-live only — there is no
            corresponding unpublish-at timestamp.
          example: null
        seo:
          $ref: "#/components/schemas/Seo"
        metafields:
          type: array
          description: |
            Custom metadata entries (each a `namespace`/`key`/`value` with a
            `value_type`) attached to the collection; empty array when none are set.
          items:
            $ref: "#/components/schemas/Metafield"
        languages:
          type: array
          items:
            type: string
          description: |
            ISO codes of locales this Collection is translated into.
            Always includes the company default locale; never empty.
          example: [en, fr]
        countries:
          type: array
          items:
            type: string
          description: |
            ISO codes restricting availability. Empty array means
            available in every country the company sells in
            (unrestricted).
          example: [GB, US]
    Collection:
      description: |
        Public Collection read shape — the storefront (unauthenticated)
        projection. Closed with `unevaluatedProperties: false` so any
        authenticated-only field (notably `custom_slug`) leaking onto the
        public surface is caught in conformance.
      allOf:
        - $ref: "#/components/schemas/CollectionBase"
      unevaluatedProperties: false
    CollectionAuthenticated:
      description: |
        Authenticated (company-surface) Collection read shape: the public
        `CollectionBase` plus `custom_slug`, admin metadata never rendered on
        the public surface.
      allOf:
        - $ref: "#/components/schemas/CollectionBase"
        - type: object
          properties:
            custom_slug:
              type: boolean
              description: |
                Authenticated-only. True when the merchant has pinned a
                permanent merchant-set slug; false when the slug is
                auto-generated from `title` and regenerates on rename.
                Rendered only on the company surface — admin metadata about
                how the slug is managed, not something a storefront consumer
                needs.
              example: false
      unevaluatedProperties: false
    CollectionListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [collections]
          properties:
            collections:
              type: array
              items:
                $ref: "#/components/schemas/Collection"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    CollectionShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [collection]
          properties:
            collection:
              $ref: "#/components/schemas/Collection"
    CollectionCompanyListResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [collections]
          properties:
            collections:
              type: array
              items:
                $ref: "#/components/schemas/CollectionAuthenticated"
            meta:
              allOf:
                - $ref: "#/components/schemas/Meta"
                - required: [pagination]
    CollectionCompanyShowResponse:
      allOf:
        - $ref: "#/components/schemas/Envelope"
        - type: object
          required: [collection]
          properties:
            collection:
              $ref: "#/components/schemas/CollectionAuthenticated"
    CollectionWrite:
      type: object
      additionalProperties: false
      properties:
        title:
          type: string
          example: Wellness Essentials
        description:
          type: [string, "null"]
        image_url:
          type: [string, "null"]
        image_path:
          type: [string, "null"]
        slug:
          type: string
          description: |
            Send `slug` + `custom_slug: true` together to pin a
            permanent merchant-set slug. Without `custom_slug: true`,
            the slug auto-regenerates from `title` on every title
            change.
        custom_slug:
          type: boolean
          description: |
            Send together with `slug` and set `true` to pin a permanent merchant-set
            slug. Without it, the slug is auto-generated from `title` and regenerates
            whenever the title changes.
        active:
          type: boolean
          description: |
            Merchant on/off toggle. Set `false` to hide the collection from every
            public surface regardless of `status`.
        status:
          type: string
          enum: [draft, scheduled, published, archived]
          description: |
            Lifecycle state to set. `published` with `active: true` makes the
            collection live; `scheduled` with a future `publish_at` goes live
            automatically when that time passes. There is no separate publish endpoint
            — visibility is changed through `active` and `status`.
        publish_at:
          type: [string, "null"]
          format: date-time
          description: |
            Target go-live timestamp used with `status: scheduled`. Once it passes,
            the row resolves to `published` at read time. Models go-live only — there
            is no unpublish-at field.
        application_theme_template_id:
          type: [integer, "null"]
        country_isos:
          type: array
          items:
            type: string
          description: |
            ISO country codes restricting availability; maps to the read-side
            `countries` field. An empty array (`[]`) clears all restrictions
            (unrestricted — available everywhere the company sells); omitting the key
            leaves the existing countries unchanged (PATCH semantics).
        product_ids:
          type: array
          items:
            type: integer
          description: |
            Full replacement set of product ids in this collection. `[]`
            empties it; key omitted leaves membership unchanged. Ids must
            belong to the authenticated store — a product from another
            store returns 422.
        metafields_attributes:
          type: array
          description: |
            Metafields to write on the collection using Rails nested-attributes
            semantics: each entry sets `namespace`, `key`, `value`, and `value_type`,
            with an optional `description`; send `_destroy: true` to remove an entry.
            Include the entry's `id` to update or destroy an existing metafield. This
            per-metafield `id` is accepted on collections only — `CategoryWrite`
            metafields do not model it, and the asymmetry is intentional (it matches
            the backend write contract).
          items:
            type: object
            required: [namespace, key, value, value_type]
            properties:
              id:
                type: integer
                description: Present = update / destroy an existing entry.
              namespace:
                type: string
              key:
                type: string
              value: {}
              value_type:
                type: string
              description:
                type: string
              _destroy:
                type: boolean
        search_engine_optimizer_attributes:
          type: [object, "null"]
          description: |
            SEO record nested attributes. The response key is renamed to
            `seo` for v202604, but the request key stays at the AR
            `accepts_nested_attributes_for` name during the migration.
          properties:
            id:
              type: integer
            title:
              type: string
            description:
              type: string
            image_url:
              type: string
            block_crawler:
              type: boolean
    CollectionCreateRequest:
      type: object
      required: [collection]
      additionalProperties: false
      properties:
        collection:
          allOf:
            - $ref: "#/components/schemas/CollectionWrite"
            - required: [title]
    CollectionUpdateRequest:
      type: object
      required: [collection]
      additionalProperties: false
      properties:
        collection:
          $ref: "#/components/schemas/CollectionWrite"
    ScanStatus:
      type: object
      description: |
        Progress of an async lighthouse / compliance scan. Only `status`
        is guaranteed (`requested_at` is present in practice once a scan
        is requested); nil timestamps are omitted, so the present keys
        vary by phase — a failed scan carries `failed_at` + `error`, a
        completed one carries `started_at` + `completed_at`.
      required: [status]
      properties:
        status:
          type: string
          enum: [pending, in_progress, completed, failed]
          example: pending
        requested_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        failed_at:
          type: string
          format: date-time
        error:
          type: string
      example:
        status: pending
        requested_at: "2026-06-23T13:42:00Z"
    SharesResponse:
      type: object
      description: |
        Share statistics for a storefront resource — the same shape every
        storefront resource returns from `GET :id/shares`: a top-level
        `share_stats` object plus the `meta` envelope. There is no
        `resource` wrapper.
      required: [share_stats, meta]
      properties:
        share_stats:
          type: object
          required: [total_shares, shares_last_30_days, percent_change, unique_sharers, total_visits]
          properties:
            total_shares:
              type: integer
              example: 1284
            shares_last_30_days:
              type: integer
              example: 96
            percent_change:
              type: [number, "null"]
              description: |
                Percent change in shares vs. the previous 30-day window
                (rounded to 2 dp). `null` when the previous window had no
                shares — there is no baseline to compare against.
              example: 12.5
            unique_sharers:
              type: integer
              example: 311
            total_visits:
              type: integer
              description: Visits to the resource's storefront URL via tracked share links.
              example: 5230
        meta:
          $ref: "#/components/schemas/Meta"
      example:
        share_stats:
          total_shares: 1284
          shares_last_30_days: 96
          percent_change: 12.5
          unique_sharers: 311
          total_visits: 5230
        meta:
          request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
          timestamp: "2026-07-15T12:34:56Z"
    LighthouseResult:
      type: object
      description: |
        Latest Lighthouse result for the resource, returned raw — no
        `meta`, no top-level `status`. `core_metrics`, `category_scores`,
        and
        `optimization_opportunities` are derived from the raw `result`
        blob; any derived value can be `null` when the run did not
        produce it.
      required:
        - id
        - scanned_at
        - created_at
        - updated_at
        - device_type
        - final_url
        - fetch_time
        - core_metrics
        - category_scores
        - optimization_opportunities
        - result
      properties:
        id:
          type: integer
          example: 55
        scanned_at:
          type: [string, "null"]
          format: date-time
          example: "2026-06-23T13:42:17Z"
        created_at:
          type: string
          format: date-time
          example: "2026-06-23T13:42:17Z"
        updated_at:
          type: string
          format: date-time
          example: "2026-06-23T13:42:20Z"
        device_type:
          type: [string, "null"]
          enum: [mobile, desktop, null]
          description: Lighthouse form factor (`formFactor` / `emulatedFormFactor`).
          example: mobile
        final_url:
          type: [string, "null"]
          example: https://acme.fluid.app/home/categories/summer-sale
        fetch_time:
          type: [string, "null"]
          format: date-time
          example: "2026-06-23T13:42:17Z"
        core_metrics:
          type: object
          description: |
            Summarized metrics derived from the raw result. Every key is
            always present; every value is nullable. Rating fields are
            `good` / `needs-improvement` / `poor` (or `null`).
          required:
            - performance_score
            - largest_contentful_paint
            - largest_contentful_paint_rating
            - first_input_delay
            - first_input_delay_rating
            - cumulative_layout_shift
            - cumulative_layout_shift_rating
            - time_to_interactive
            - total_blocking_time
            - first_contentful_paint
            - speed_index
            - device_type
          properties:
            performance_score:
              type: [number, "null"]
            largest_contentful_paint:
              type: [number, "null"]
            largest_contentful_paint_rating:
              type: [string, "null"]
            first_input_delay:
              type: [number, "null"]
            first_input_delay_rating:
              type: [string, "null"]
            cumulative_layout_shift:
              type: [number, "null"]
            cumulative_layout_shift_rating:
              type: [string, "null"]
            time_to_interactive:
              type: [number, "null"]
            total_blocking_time:
              type: [number, "null"]
            first_contentful_paint:
              type: [number, "null"]
            speed_index:
              type: [number, "null"]
            device_type:
              type: [string, "null"]
        category_scores:
          type: object
          description: |
            Per-Lighthouse-category score, 0–100 (`(score * 100).round`)
            or `null`. Keys are whatever categories the run produced
            (e.g. performance, accessibility, best-practices, seo, pwa).
          additionalProperties:
            type: [integer, "null"]
          example:
            performance: 91
            accessibility: 96
            best-practices: 92
            seo: 100
            pwa: 70
        optimization_opportunities:
          type: array
          description: |
            Audits with a savings opportunity (score < 1), sorted by
            estimated savings descending.
          items:
            type: object
            required: [id, title]
            properties:
              id:
                type: string
                example: uses-optimized-images
              title:
                type: [string, "null"]
                example: Efficiently encode images
              description:
                type: [string, "null"]
              score:
                type: [number, "null"]
                example: 0.72
              display_value:
                type: [string, "null"]
                example: Potential savings of 120 KiB
              savings_ms:
                type: [number, "null"]
                example: 340
              savings_bytes:
                type: [number, "null"]
                example: 122880
        result:
          type: object
          additionalProperties: true
          description: |
            Raw Lighthouse JSON blob (the upstream Google Lighthouse
            schema). Large (~100 KB typical); the summarized `core_metrics`,
            `category_scores`, and `optimization_opportunities` above are
            derived from it.
      example:
        id: 55
        scanned_at: "2026-06-23T13:42:17Z"
        created_at: "2026-06-23T13:42:17Z"
        updated_at: "2026-06-23T13:42:20Z"
        device_type: mobile
        final_url: https://acme.fluid.app/home/categories/summer-sale
        fetch_time: "2026-06-23T13:42:17Z"
        core_metrics:
          performance_score: 0.91
          largest_contentful_paint: 1850
          largest_contentful_paint_rating: good
          first_input_delay: null
          first_input_delay_rating: null
          cumulative_layout_shift: 0.03
          cumulative_layout_shift_rating: good
          time_to_interactive: 2400
          total_blocking_time: 120
          first_contentful_paint: 1200
          speed_index: 2100
          device_type: mobile
        category_scores:
          performance: 91
          accessibility: 96
          best-practices: 92
          seo: 100
          pwa: 70
        optimization_opportunities:
          - id: uses-optimized-images
            title: Efficiently encode images
            description: Optimized images load faster and consume less cellular data.
            score: 0.72
            display_value: Potential savings of 120 KiB
            savings_ms: 340
            savings_bytes: 122880
        result:
          lighthouseVersion: "11.4.0"
          finalDisplayedUrl: https://acme.fluid.app/home/categories/summer-sale
    ComplianceResult:
      type: object
      description: |
        Latest compliance scan result for the resource: the record nested
        under `item` plus the `meta` envelope. Nullable fields are
        unpopulated until the first successful scan.
      required: [item, meta]
      properties:
        item:
          type: object
          required: [id, status, service, compliance_issues, created_at, updated_at]
          properties:
            id:
              type: integer
              example: 77
            score:
              type: [string, "null"]
              description: |
                0.0–10.0; higher is better. Serialized as a decimal string —
                the backing column is `decimal(4,2)`, which Rails renders as a
                JSON string, not a number. `null` before the first successful
                scan.
              example: "8.4"
            status:
              type: string
              description: |
                Derived from `score` — `null` → `unknown`, `<4` → `poor`,
                `<6` → `fair`, `<8` → `good`, `>=8` → `excellent`.
              enum: [unknown, poor, fair, good, excellent]
              example: excellent
            summary:
              type: [string, "null"]
              example: All required disclosures present; no banned phrases detected.
            scanned_at:
              type: [string, "null"]
              format: date-time
              example: "2026-06-23T13:42:33Z"
            service:
              type: string
              description: Name of the scanning service that produced the result.
              example: openai
            created_at:
              type: string
              format: date-time
              example: "2026-06-23T13:42:33Z"
            updated_at:
              type: string
              format: date-time
              example: "2026-06-23T13:42:33Z"
            compliance_issues:
              type: array
              description: Per-issue findings; empty when the resource is compliant.
              items:
                type: object
                required: [id, issue_text, issue_type, created_at, updated_at]
                properties:
                  id:
                    type: integer
                    example: 401
                  issue_text:
                    type: string
                    example: Missing income disclosure on the earnings claim.
                  issue_reference:
                    type: [string, "null"]
                    example: FTC 16 CFR Part 255
                  issue_type:
                    type: string
                    example: disclosure
                  severity:
                    type: [string, "null"]
                    description: One of `low` / `medium` / `high` / `critical`, or `null`.
                    example: high
                  recommendation:
                    type: [string, "null"]
                    example: Add an FTC-compliant income disclosure adjacent to the claim.
                  created_at:
                    type: string
                    format: date-time
                  updated_at:
                    type: string
                    format: date-time
        meta:
          $ref: "#/components/schemas/Meta"
      example:
        item:
          id: 77
          score: "8.4"
          status: excellent
          summary: All required disclosures present; no banned phrases detected.
          scanned_at: "2026-06-23T13:42:33Z"
          service: openai
          created_at: "2026-06-23T13:42:33Z"
          updated_at: "2026-06-23T13:42:33Z"
          compliance_issues: []
        meta:
          request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
          timestamp: "2026-07-15T12:34:56Z"
    ScanQueued:
      type: object
      description: |
        Response from `POST :id/lighthouse` and `POST :id/compliance` — the
        scan was accepted and queued, and the body carries the `meta`
        envelope. The new result lands at the corresponding GET endpoint
        when the job completes.
      required: [message, scan_status, resource_type, resource_id, meta]
      properties:
        message:
          type: string
          example: Lighthouse scan queued
        scan_status:
          $ref: "#/components/schemas/ScanStatus"
        resource_type:
          type: string
          description: PascalCase type name of the scanned resource.
          enum: [Category, Collection, Product, Library, Medium, Post, EnrollmentPack, Page]
          example: Category
        resource_id:
          type: integer
          example: 4821
        meta:
          $ref: "#/components/schemas/Meta"
      example:
        message: Lighthouse scan queued
        scan_status:
          status: pending
          requested_at: "2026-06-23T13:42:00Z"
        resource_type: Category
        resource_id: 4821
        meta:
          request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
          timestamp: "2026-07-15T12:34:56Z"
    LighthouseScanTriggered:
      type: object
      description: |
        `GET :id/lighthouse` 202 — returned when no Lighthouse scan exists
        yet, so one is auto-triggered. The body is raw — there is NO `meta`
        envelope.
      required: [message, scan_status]
      properties:
        message:
          type: string
          example: No Lighthouse scan exists. Scan has been triggered automatically.
        scan_status:
          $ref: "#/components/schemas/ScanStatus"
      example:
        message: No Lighthouse scan exists. Scan has been triggered automatically.
        scan_status:
          status: pending
          requested_at: "2026-06-23T13:42:00Z"
    ComplianceScanTriggered:
      type: object
      description: |
        `GET :id/compliance` 202 — returned when no compliance scan exists
        yet, so one is auto-triggered. The body carries the `meta`
        envelope.
      required: [message, scan_status, meta]
      properties:
        message:
          type: string
          example: No Compliance scan exists. Scan has been triggered automatically.
        scan_status:
          $ref: "#/components/schemas/ScanStatus"
        meta:
          $ref: "#/components/schemas/Meta"
      example:
        message: No Compliance scan exists. Scan has been triggered automatically.
        scan_status:
          status: pending
          requested_at: "2026-06-23T13:42:00Z"
        meta:
          request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
          timestamp: "2026-07-15T12:34:56Z"
paths:
  /api/v202604/categories:
    get:
      tags: [storefront]
      summary: Public Categories catalog
      operationId: storefrontCategoriesIndex
      description: |
        Unauthenticated. Company is resolved from the request subdomain.
        Returns ONLY live categories (active + resolved-published,
        country-eligible, language-eligible) — non-public rows never
        leak.

        Filters, search, and pagination are documented on each parameter.
      security: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[source_type]
          in: query
          required: false
          description: |
            One source type: `product`, `medium`, `page`, `library`, or `promotion`.
            Categories only — the collection catalog does not accept this filter.
          schema:
            type: string
            enum: [product, medium, page, library, promotion]
        - name: filter[parent_id]
          in: query
          required: false
          description: |
            A category id as a string, or the literal `"root"` for top-level
            categories only. Selects a single level of the tree. Categories
            only — collections have no hierarchy. Honored on both the public
            catalog and the company index, which share one query contract.
          schema:
            type: string
            example: "root"
        - $ref: "#/components/parameters/Sort"
      responses:
        "200":
          description: A page of live categories.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryListResponse"
              example:
                status: 200
                categories:
                  - id: 4821
                    slug: summer-sale
                    title: Summer Sale
                    description: Hand-picked categories for the July clearance.
                    image_url: https://cdn.fluid.app/categories/4821.png
                    canonical_url: https://acme.fluid.app/home/categories/summer-sale
                    images:
                      thumb:  { url: "https://ik.imagekit.io/fluidapp/tr:w-200,c-at_max,f-auto,q-70/categories/4821.png",  width: 200 }
                      medium: { url: "https://ik.imagekit.io/fluidapp/tr:w-600,c-at_max,f-auto,q-75/categories/4821.png",  width: 600 }
                      large:  { url: "https://ik.imagekit.io/fluidapp/tr:w-1200,c-at_max,f-auto,q-80/categories/4821.png", width: 1200 }
                    active: true
                    status: published
                    publish_at: null
                    seo:
                      id: 9215
                      title: Summer Sale — Up to 40% Off
                      description: Shop the July clearance event before it ends.
                      image_url: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/9215.png
                      block_crawler: false
                    metafields: []
                    countries: [GB, US]
                    languages: [en, fr]
                    parent_id: null
                    position: 1
                    source_type: product
                    has_children: false
                  - id: 4822
                    slug: clearance
                    title: Clearance
                    description: Last-chance prices on overstock items.
                    image_url: null
                    canonical_url: https://acme.fluid.app/home/categories/clearance
                    images: null
                    active: true
                    status: published
                    publish_at: null
                    seo:
                      title: Clearance
                      description: Last-chance prices on overstock items.
                      image_url: https://ik.imagekit.io/fluid/980191006/images/VJBWJ3/VJBWJ3_QDwktuK8Ji.webp
                      block_crawler: false
                    metafields: []
                    countries: []
                    languages: [en]
                    parent_id: null
                    position: 2
                    source_type: product
                    has_children: false
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                  pagination:
                    cursor: null
                    next_cursor: eyJpZCI6NDgyMn0
                    prev_cursor: null
                    limit: 25
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/categories/{slug}:
    get:
      tags: [storefront]
      summary: Public Category by slug
      operationId: storefrontCategoryShow
      description: |
        Unauthenticated. Resolves by slug (the canonical public
        identifier; matches `canonical_url`). Returns 404 for any
        non-live row so unlisted categories never leak their existence.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          description: |
            Canonical public slug of the category; matches the row's `canonical_url`.
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Category.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryShowResponse"
              example:
                status: 200
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                category:
                  id: 4821
                  slug: summer-sale
                  title: Summer Sale
                  description: Hand-picked categories for the July clearance.
                  image_url: https://cdn.fluid.app/categories/4821.png
                  canonical_url: https://acme.fluid.app/home/categories/summer-sale
                  images:
                    thumb:  { url: "https://ik.imagekit.io/fluidapp/tr:w-200,c-at_max,f-auto,q-70/categories/4821.png",  width: 200 }
                    medium: { url: "https://ik.imagekit.io/fluidapp/tr:w-600,c-at_max,f-auto,q-75/categories/4821.png",  width: 600 }
                    large:  { url: "https://ik.imagekit.io/fluidapp/tr:w-1200,c-at_max,f-auto,q-80/categories/4821.png", width: 1200 }
                  active: true
                  status: published
                  publish_at: null
                  seo:
                    id: 9215
                    title: Summer Sale — Up to 40% Off
                    description: Shop the July clearance event before it ends.
                    image_url: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/9215.png
                    block_crawler: false
                  metafields:
                    - namespace: custom
                      key: theme_accent
                      value: "#f5a623"
                      value_type: single_line_text_field
                      description: Accent color for the category landing page.
                      created_at: "2026-05-01T12:00:00Z"
                      updated_at: "2026-05-01T12:00:00Z"
                      locked: false
                  countries: [GB, US]
                  languages: [en, fr, de]
                  parent_id: null
                  position: 1
                  source_type: product
                  has_children: false
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/categories/{slug}/products:
    get:
      tags: [storefront]
      summary: Products in a Category (public)
      operationId: storefrontCategoryProducts
      description: |
        PUBLIC endpoint — NO authentication required. Company is resolved
        from the request subdomain.

        The products catalog scoped to one category, not a second catalog:
        the response body, filters, sorting, search, country-relative
        pricing, and cursor pagination are identical to
        `GET /api/v202604/products`. Returns ONLY live products (active +
        resolved-published) with an active variant in the requested
        `country`.

        The category in the path always wins — a `filter[category_ids]` in the
        query string cannot widen the listing beyond it. Returns 404 when
        no live category matches the slug.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          description: The category's slug, as returned in its `slug` field.
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - name: filter[availability]
          in: query
          required: false
          description: |
            Stock filter. `in_stock` restricts to purchasable products;
            `all` (default) imposes no stock constraint.
          schema:
            type: string
            enum: [all, in_stock]
            default: all
        - name: filter[bundle]
          in: query
          required: false
          description: |
            `true` returns only bundle products; `false` returns only
            non-bundle products.
          schema:
            type: boolean
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default
            `priority` then `id`.
          schema:
            type: string
            enum: [priority, -priority, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of live products in this category, with country-relative pricing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductListResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
  /api/v202604/company/categories:
    get:
      tags: [company]
      summary: Company Categories index
      operationId: companyCategoriesIndex
      description: |
        Token-authenticated. Returns EVERY Category for the calling
        company regardless of lifecycle (draft / scheduled / archived
        rows included), so admins can manage non-public rows.

        Supports the same cursor pagination / filter / sort / `q`
        contract as the public catalog — `filter[source_type]` and
        `filter[parent_id]` behave identically here (one shared
        `ListParams`). Adds `filter[status]` and `filter[active]`,
        which are accepted on both surfaces but only take effect here.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[source_type]
          in: query
          required: false
          description: |
            One source type: `product`, `medium`, `page`, `library`, or `promotion`.
            Categories only — the collection catalog does not accept this filter.
          schema:
            type: string
            enum: [product, medium, page, library, promotion]
        - name: filter[parent_id]
          in: query
          required: false
          description: |
            A category id as a string, or the literal `"root"` for top-level
            categories only. Selects a single level of the tree. Categories
            only — collections have no hierarchy. Honored on both the public
            catalog and the company index, which share one query contract.
          schema:
            type: string
            example: "root"
        - name: filter[status]
          in: query
          required: false
          description: |
            One of `draft`, `scheduled`, `published`, or `archived`. Restricts the
            index to a single lifecycle state. Company-surface only. Matches the
            STORED `status` column, not the read-time-resolved status: a past-due
            `scheduled` row (its `publish_at` has passed, so it renders as
            `published`) is matched by `filter[status]=scheduled`, NOT by
            `filter[status]=published`.
          schema:
            type: string
            enum: [draft, scheduled, published, archived]
        - $ref: "#/components/parameters/FilterActive"
        - $ref: "#/components/parameters/Sort"
      responses:
        "200":
          description: A page of categories.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryCompanyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

    post:
      tags: [company]
      summary: Create a Category
      operationId: companyCategoriesCreate
      description: |
        Token-authenticated; requires `storefront.update` (or legacy
        `categories.update` during the migration).

        Triggers fresh Lighthouse + compliance scans automatically
        — a subsequent `GET :id/lighthouse` / `:id/compliance`
        returns `scan_status: pending` while the queued scans run.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CategoryCreateRequest"
            examples:
              minimal:
                summary: Minimal create
                value:
                  category:
                    title: Summer Sale
              full:
                summary: Full create
                value:
                  category:
                    title: Summer Sale
                    description: Hand-picked categories for the July clearance.
                    slug: summer-sale
                    custom_slug: true
                    active: true
                    status: scheduled
                    publish_at: "2026-07-01T08:00:00Z"
                    parent_id: null
                    position: 1
                    source_type: product
                    country_isos: [GB, US]
                    metafields_attributes:
                      - namespace: custom
                        key: theme_accent
                        value: "#f5a623"
                        value_type: single_line_text_field
                    search_engine_optimizer_attributes:
                      title: Summer Sale — Up to 40% Off
                      description: Shop the July clearance event before it ends.
                      image_url: https://ik.imagekit.io/fluid/seo/9215.png
                      block_crawler: false
      responses:
        "201":
          description: The created Category.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryCompanyShowResponse"
              example:
                status: 201
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                category:
                  id: 4821
                  slug: summer-sale
                  title: Summer Sale
                  description: Hand-picked categories for the July clearance.
                  image_url: null
                  canonical_url: https://acme.fluid.app/home/categories/summer-sale
                  images: null
                  active: true
                  status: scheduled
                  publish_at: "2026-07-01T08:00:00Z"
                  seo:
                    id: 9215
                    title: Summer Sale — Up to 40% Off
                    description: Shop the July clearance event before it ends.
                    image_url: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/9215.png
                    block_crawler: false
                  metafields:
                    - namespace: custom
                      key: theme_accent
                      value: "#f5a623"
                      value_type: single_line_text_field
                      description: null
                      created_at: "2026-06-23T14:00:00Z"
                      updated_at: "2026-06-23T14:00:00Z"
                      locked: false
                  countries: [GB, US]
                  languages: [en]
                  parent_id: null
                  position: 1
                  source_type: product
                  has_children: false
                  custom_slug: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/categories/{id}:
    get:
      tags: [company]
      summary: Company Category show
      operationId: companyCategoriesShow
      description: |
        Token-authenticated; resolves by id (the canonical company
        identifier). Returns any Category regardless of lifecycle.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          in: path
          required: true
          description: |
            Numeric id of the category — the canonical company identifier.
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Category.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [company]
      summary: Update a Category
      operationId: companyCategoriesUpdate
      description: |
        Token-authenticated. PATCH semantics — only keys present in the
        payload are updated.

        Triggers fresh Lighthouse + compliance scans automatically on
        every successful PATCH.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the category.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CategoryUpdateRequest"
            examples:
              rename:
                summary: Rename (auto-generated slug regenerates)
                value:
                  category:
                    title: End-of-Summer Sale
              pinSlug:
                summary: Rename and pin a permanent slug
                value:
                  category:
                    title: End-of-Summer Sale
                    slug: summer-sale
                    custom_slug: true
              publish:
                summary: Publish (make live)
                value:
                  category:
                    active: true
                    status: published
              unpublish:
                summary: Unpublish (quick on/off, preserves lifecycle state)
                value:
                  category:
                    active: false
              schedule:
                summary: Schedule go-live — replace publish_at with any future timestamp
                value:
                  category:
                    active: true
                    status: scheduled
                    publish_at: "2999-01-15T08:00:00Z"
              setCountries:
                summary: Restrict country availability
                value:
                  category:
                    country_isos: [GB, US]
              clearCountries:
                summary: Clear restrictions (available everywhere the company sells)
                value:
                  category:
                    country_isos: []
              promoteToRoot:
                summary: Promote to top level
                value:
                  category:
                    parent_id: null
      responses:
        "200":
          description: The updated Category.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

    delete:
      tags: [company]
      summary: Destroy a Category
      operationId: companyCategoriesDestroy
      description: |
        Token-authenticated. HARD-delete — Category does not use the
        Discard concern (the `archived` lifecycle state is the
        soft-deprecation path, not delete). Because categories form a
        tree, deleting one CASCADE-DESTROYS its descendant categories;
        products, media, and pages filed under it are unlinked (their
        `category_id` is nulled), not deleted. Emits a `categories.deleted`
        webhook post-commit so subscribers can react.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the category.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/categories/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for a Category
      operationId: companyCategoriesShares
      description: |
        Read-only aggregate share stats. Same response shape across all
        seven storefront resources.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the category.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/categories/{id}/products:
    get:
      tags: [company]
      summary: Products in a Category
      operationId: companyCategoryProducts
      description: |
        Every product filed under this category, including the draft and
        archived rows the public listing hides.

        Same response body, filters, sorting, search, country-relative
        pricing, and cursor pagination as
        `GET /api/v202604/company/products`. The category in the path always
        wins — a `filter[category_ids]` in the query string cannot widen the
        listing beyond it.

        Requires `storefront.view` (legacy `categories.view` still accepted).
      parameters:
        - name: id
          description: The unique identifier of the category whose products to list.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - name: filter[status]
          in: query
          required: false
          description: Restrict to a single stored lifecycle state.
          schema:
            type: string
            enum: [active, draft, archived]
        - name: filter[availability]
          in: query
          required: false
          description: |
            Stock filter. `in_stock` restricts to purchasable products;
            `all` (default) imposes no stock constraint.
          schema:
            type: string
            enum: [all, in_stock]
            default: all
        - name: filter[bundle]
          in: query
          required: false
          description: |
            `true` returns only bundle products; `false` returns only
            non-bundle products.
          schema:
            type: boolean
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default
            `priority` then `id`.
          schema:
            type: string
            enum: [priority, -priority, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of products in this category, every lifecycle state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
  /api/v202604/company/categories/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result
      operationId: companyCategoriesLighthouseShow
      description: |
        Returns the most recent successful Lighthouse scan for the Category
        (scans persist only on success). A fresh scan is auto-queued on
        every company create and update, but it runs asynchronously — a
        failed, rate-limited, or recently-run rescan writes no new record,
        so this endpoint may return the prior scan with HTTP 200. `POST
        .../lighthouse` requests a rescan (it always returns 202) but
        cannot guarantee a newer score — the queued scan is skipped
        silently when one ran in the last ~5 minutes, the scanner is
        rate-limited, or its circuit breaker is open.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the category.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan
      operationId: companyCategoriesLighthouseScan
      description: |
        Queues a fresh Lighthouse scan. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/lighthouse` when
        the job completes (usually <60s).

        Manual triggers are usually unnecessary — every company create
        and update auto-queues a scan. Use this when an out-of-band
        change (DNS, CDN config, theme deploy) means the Lighthouse
        score should be re-evaluated without a resource write.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the category.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/categories/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result
      operationId: companyCategoriesComplianceShow
      description: |
        Returns the most recent compliance scan result for the Category.
        A fresh scan is queued automatically on every company create +
        update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the category.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan
      operationId: companyCategoriesComplianceScan
      description: |
        Queues a fresh compliance scan. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/compliance` when
        the job completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the category.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/products:
    get:
      tags: [storefront]
      summary: Public Products catalog
      operationId: storefrontProductsIndex
      description: |
        PUBLIC endpoint — NO authentication required. Company is resolved
        from the request subdomain. Returns ONLY live products (active +
        resolved-published) that have an active variant in the requested
        `country`. Non-live rows never leak.

        Product price is COUNTRY-RELATIVE: the `country` parameter selects
        which per-country variant price/currency is rendered (and drops
        products with no active variant in that country). When `country` is
        omitted, pricing falls back to the company's default country.
        Attribution params (e.g. `fluid_rep_id`) may be accepted as
        cache-neutral pass-through but never change the response body.

        Filters, search, and pagination are documented on each parameter.
      security: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - name: filter[availability]
          in: query
          required: false
          description: |
            Stock filter. `in_stock` restricts to purchasable products;
            `all` (default) imposes no stock constraint.
          schema:
            type: string
            enum: [all, in_stock]
            default: all
        - name: filter[bundle]
          in: query
          required: false
          description: |
            `true` returns only bundle products; `false` returns only
            non-bundle products.
          schema:
            type: boolean
        - name: filter[category_ids]
          in: query
          required: false
          description: Restrict to products in any of these category ids.
          schema:
            type: array
            items:
              type: integer
        - name: filter[collection_ids]
          in: query
          required: false
          description: Restrict to products in any of these collection ids.
          schema:
            type: array
            items:
              type: integer
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default
            `priority` then `id`.
          schema:
            type: string
            enum: [priority, -priority, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of live products with country-relative pricing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductListResponse"
              example:
                status: 200
                products:
                  - id: 60311
                    slug: scrute-farms-beet-blend
                    title: Schrute Farms Beet Blend
                    canonical_url: https://acme.fluid.app/home/products/scrute-farms-beet-blend
                    images:
                      thumb:  { url: "https://ik.imagekit.io/fluidapp/tr:w-200,c-at_max,f-auto,q-70/products/60311.png",  width: 200 }
                      medium: { url: "https://ik.imagekit.io/fluidapp/tr:w-600,c-at_max,f-auto,q-75/products/60311.png",  width: 600 }
                      large:  { url: "https://ik.imagekit.io/fluidapp/tr:w-1200,c-at_max,f-auto,q-80/products/60311.png", width: 1200 }
                    active: true
                    status: published
                    publish_at: null
                    metafields: []
                    languages: [en, fr]
                    sku: SCRUTE-BEET-001
                    is_bundle: false
                    in_stock: true
                    has_subscription_plans: true
                    pricing:
                      price: "29.99"
                      currency_code: USD
                      compare_at: "39.99"
                      subscription_price: "26.99"
                      display_price: "$29.99"
                    default_variant:
                      id: 880912
                      sku: SCRUTE-BEET-001-12OZ
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                  pagination:
                    cursor: null
                    next_cursor: eyJpZCI6NjAzMTF9
                    prev_cursor: null
                    limit: 25
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/products/{slug}:
    get:
      tags: [storefront]
      summary: Public Product by slug
      operationId: storefrontProductShow
      description: |
        PUBLIC endpoint — NO authentication required. Resolves by slug (the
        canonical public identifier; matches `canonical_url`). Show is more
        permissive than the catalog: draft, scheduled (future `publish_at`),
        and inactive products still resolve by direct slug — so preview and
        pre-launch links load — but render `noindex`. Only archived and
        discarded products return 404, so retired products never leak their
        existence. (The catalog and search remain live-only.)

        Product price is COUNTRY-RELATIVE: the `country` parameter selects
        the per-country variant price/currency rendered in `pricing` and
        `variant_countries`, defaulting to the company's default country
        when omitted.
      security: []
      parameters:
        - name: slug
          description: The URL slug of the product.
          in: path
          required: true
          schema:
            type: string
            example: scrute-farms-beet-blend
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
      responses:
        "200":
          description: The Product with country-relative pricing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductShowResponse"
              example:
                status: 200
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                product:
                  id: 60311
                  slug: scrute-farms-beet-blend
                  title: Schrute Farms Beet Blend
                  description: A cold-pressed beet concentrate from the finest acres on Schrute Farms.
                  image_url: https://cdn.fluid.app/products/60311.png
                  canonical_url: https://acme.fluid.app/home/products/scrute-farms-beet-blend
                  images:
                    thumb:  { url: "https://ik.imagekit.io/fluidapp/tr:w-200,c-at_max,f-auto,q-70/products/60311.png",  width: 200 }
                    medium: { url: "https://ik.imagekit.io/fluidapp/tr:w-600,c-at_max,f-auto,q-75/products/60311.png",  width: 600 }
                    large:  { url: "https://ik.imagekit.io/fluidapp/tr:w-1200,c-at_max,f-auto,q-80/products/60311.png", width: 1200 }
                  active: true
                  status: published
                  publish_at: null
                  seo:
                    title: Schrute Farms Beet Blend — Cold Pressed
                    description: Single-origin beet concentrate, pressed within the hour.
                    image_url: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/60311.png
                    block_crawler: false
                  metafields:
                    - namespace: custom
                      key: harvest_acre
                      value: North Forty
                      value_type: single_line_text_field
                      description: Which acre the beets came from.
                      created_at: "2026-05-01T12:00:00Z"
                      updated_at: "2026-05-01T12:00:00Z"
                      locked: false
                  languages: [en, fr]
                  sku: SCRUTE-BEET-001
                  is_bundle: false
                  in_stock: true
                  has_subscription_plans: true
                  pricing:
                    price: "29.99"
                    currency_code: USD
                    compare_at: "39.99"
                    subscription_price: "26.99"
                    display_price: "$29.99"
                  default_variant:
                    id: 880912
                    sku: SCRUTE-BEET-001-12OZ
                  variants:
                    - id: 880912
                      sku: SCRUTE-BEET-001-12OZ
                      is_master: true
                      title: 12 oz
                      position: 1
                      allow_subscription: true
                      variant_countries:
                        US:
                          id: 451233
                          active: true
                          currency_code: USD
                          price: "29.99"
                          compare_price: "39.99"
                          subscription_price: "26.99"
                          cv: "20"
                          qv: "20"
                          points: 100
                          country_id: 233
                          country_name: United States
                          display_price: "$29.99"
                          buyable: true
                  subscription_plans:
                    - id: 7741
                      default: true
                      active: true
                      subscription_plan:
                        id: 4410
                        name: Monthly Beet Club
                        billing_interval: 1
                        billing_interval_unit: month
                        billing_frequency_in_words: Every month
                        active: true
                        subscription_price: "$26.99"
                        savings_label: Subscribe & save 10%
                  media: []
                  bundle_groups: []
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/products:
    get:
      tags: [company]
      summary: Company Products index
      operationId: companyProductsIndex
      description: |
        Token-authenticated. Returns EVERY Product for the calling company
        regardless of lifecycle (draft / archived rows included), so admins
        can manage non-public rows.

        Supports the same cursor pagination / filter / sort / `q` contract
        as the public catalog. `filter[status]` (the raw Product enum keys
        `active`/`draft`/`archived`) is only meaningful on this surface.
        Product price is COUNTRY-RELATIVE — the `country` parameter selects
        the per-country variant price/currency rendered.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - name: filter[status]
          in: query
          required: false
          description: Raw Product enum key.
          schema:
            type: string
            enum: [active, draft, archived]
        - name: filter[availability]
          in: query
          required: false
          description: |
            Stock filter. `in_stock` restricts to purchasable products; `all`
            (default) imposes no stock constraint.
          schema:
            type: string
            enum: [all, in_stock]
            default: all
        - name: filter[bundle]
          in: query
          required: false
          description: |
            `true` returns only bundle products; `false` returns only non-bundle
            products.
          schema:
            type: boolean
        - name: filter[category_ids]
          in: query
          required: false
          description: |
            Restrict to products in any of these category ids.
          schema:
            type: array
            items:
              type: integer
        - name: filter[collection_ids]
          in: query
          required: false
          description: |
            Restrict to products in any of these collection ids.
          schema:
            type: array
            items:
              type: integer
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default `priority` then
            `id`.
          schema:
            type: string
            enum: [priority, -priority, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of products.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

    post:
      tags: [company]
      summary: Create a Product
      operationId: companyProductsCreate
      description: |
        Token-authenticated; requires `storefront.update` (or legacy
        `products.update` during the migration).

        The write keeps the ActiveRecord `accepts_nested_attributes_for` key
        names so the existing Retail::ProductManager / VariantManager
        orchestration consumes the payload unchanged. A variant MUST be
        priced in at least one country on create
        (`variant_countries_attributes` required). Dynamic bundle-group
        writes are NOT accepted; static `product_bundles_attributes` are.

        Triggers fresh Lighthouse + compliance scans automatically — a
        subsequent `GET :id/lighthouse` / `:id/compliance` returns
        `scan_status: pending` while the queued scans run.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductCreateRequest"
            examples:
              minimal:
                summary: Minimal create
                value:
                  product:
                    title: Schrute Farms Beet Blend
                    variants_attributes:
                      - is_master: true
                        title: 12 oz
                        variant_countries_attributes:
                          - active: true
                            country_id: 233
                            price: "29.99"
              full:
                summary: Full create
                value:
                  product:
                    title: Schrute Farms Beet Blend
                    description: A cold-pressed beet concentrate from the finest acres on Schrute Farms.
                    sku: SCRUTE-BEET-001
                    slug: scrute-farms-beet-blend
                    custom_slug: true
                    status: published
                    public: true
                    publish_at: null
                    bundle: false
                    category_id: 4821
                    collection_ids: [501, 502]
                    metafields_attributes:
                      - namespace: custom
                        key: harvest_acre
                        value: North Forty
                        value_type: single_line_text_field
                    search_engine_optimizer_attributes:
                      title: Schrute Farms Beet Blend — Cold Pressed
                      description: Single-origin beet concentrate, pressed within the hour.
                      image_url: https://ik.imagekit.io/fluid/seo/60311.png
                      block_crawler: false
                    product_subscription_plans_attributes:
                      - subscription_plan_id: 4410
                        default: true
                        active: true
                    variants_attributes:
                      - is_master: true
                        sku: SCRUTE-BEET-001-12OZ
                        title: 12 oz
                        display_name: Schrute Farms Beet Blend — 12 oz
                        position: 1
                        track_quantity: true
                        variant_countries_attributes:
                          - active: true
                            country_id: 233
                            price: "29.99"
                            subscription_price: "26.99"
                            compare_price: "39.99"
                        inventory_levels_attributes:
                          - warehouse_id: 12
                            available: 142
                            committed: 3
                            on_hand: 145
                            unavailable: 0
      responses:
        "201":
          description: The created Product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductCompanyShowResponse"
              example:
                status: 201
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                product:
                  id: 60311
                  slug: scrute-farms-beet-blend
                  title: Schrute Farms Beet Blend
                  description: A cold-pressed beet concentrate from the finest acres on Schrute Farms.
                  image_url: null
                  canonical_url: https://acme.fluid.app/home/products/scrute-farms-beet-blend
                  images: null
                  active: true
                  status: published
                  publish_at: null
                  seo:
                    id: 60315
                    title: Schrute Farms Beet Blend — Cold Pressed
                    description: Single-origin beet concentrate, pressed within the hour.
                    image_url: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/60311.png
                    block_crawler: false
                  metafields:
                    - namespace: custom
                      key: harvest_acre
                      value: North Forty
                      value_type: single_line_text_field
                      description: null
                      created_at: "2026-06-23T14:00:00Z"
                      updated_at: "2026-06-23T14:00:00Z"
                      locked: false
                  languages: [en]
                  sku: SCRUTE-BEET-001
                  is_bundle: false
                  in_stock: true
                  has_subscription_plans: true
                  pricing:
                    price: "29.99"
                    currency_code: USD
                    compare_at: "39.99"
                    subscription_price: "26.99"
                    display_price: "$29.99"
                  default_variant:
                    id: 880912
                    sku: SCRUTE-BEET-001-12OZ
                  variants:
                    - id: 880912
                      sku: SCRUTE-BEET-001-12OZ
                      is_master: true
                      title: 12 oz
                      position: 1
                      allow_subscription: true
                      variant_countries:
                        US:
                          id: 451233
                          active: true
                          currency_code: USD
                          price: "29.99"
                          compare_price: "39.99"
                          subscription_price: "26.99"
                          cv: "20"
                          qv: "20"
                          points: 100
                          country_id: 233
                          country_name: United States
                          display_price: "$29.99"
                          buyable: true
                  subscription_plans:
                    - id: 7741
                      default: true
                      active: true
                      subscription_plan:
                        id: 4410
                        name: Monthly Beet Club
                        billing_interval: 1
                        billing_interval_unit: month
                        billing_frequency_in_words: Every month
                        active: true
                        subscription_price: "$26.99"
                        savings_label: Subscribe & save 10%
                  media: []
                  bundle_groups: []
                  custom_slug: true
                  gallery: []
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/products/{id}:
    get:
      tags: [company]
      summary: Company Product show
      operationId: companyProductsShow
      description: |
        Token-authenticated; resolves by id (the canonical company
        identifier). Returns any Product regardless of lifecycle. Pricing is
        COUNTRY-RELATIVE — the `country` parameter selects the per-country
        variant price/currency. The response includes the position-ordered
        product `gallery` with DAM identity for write reconciliation.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
      responses:
        "200":
          description: The Product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

    patch:
      tags: [company]
      summary: Update a Product
      operationId: companyProductsUpdate
      description: |
        Token-authenticated. PATCH semantics — only keys present in the
        payload are updated. On update `variant_countries_attributes` is
        OPTIONAL (a PATCH may touch a variant's scalars without resending
        its full per-country price matrix). Same nested write contract and
        `accepts_nested_attributes_for` key names as create.

        Triggers fresh Lighthouse + compliance scans automatically on every
        successful PATCH.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProductUpdateRequest"
            examples:
              priceUpdate:
                summary: Update one variant's US price
                value:
                  product:
                    variants_attributes:
                      - id: 880912
                        variant_countries_attributes:
                          - id: 451233
                            price: "27.99"
              archive:
                summary: Archive the product
                value:
                  product:
                    status: archived
      responses:
        "200":
          description: The updated Product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

    delete:
      tags: [company]
      summary: Destroy a Product
      operationId: companyProductsDestroy
      description: |
        Token-authenticated. Soft-deletes the Product (Discard) and emits a
        `product.discarded` webhook post-commit so subscribers can react.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/products/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for a Product
      operationId: companyProductsShares
      description: |
        Read-only aggregate share stats. Same response shape across all
        seven storefront resources.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/products/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result
      operationId: companyProductsLighthouseShow
      description: |
        Returns the most recent successful Lighthouse scan for the Product
        (scans persist only on success). A fresh scan is auto-queued on
        every company create and update, but it runs asynchronously — a
        failed, rate-limited, or recently-run rescan writes no new record,
        so this endpoint may return the prior scan with HTTP 200. `POST
        .../lighthouse` requests a rescan (it always returns 202) but
        cannot guarantee a newer score — the queued scan is skipped
        silently when one ran in the last ~5 minutes, the scanner is
        rate-limited, or its circuit breaker is open.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan
      operationId: companyProductsLighthouseScan
      description: |
        Queues a fresh Lighthouse scan. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/lighthouse` when the
        job completes (usually <60s).

        Manual triggers are usually unnecessary — every company create and
        update auto-queues a scan. Use this when an out-of-band change
        (DNS, CDN config, theme deploy) means the Lighthouse score should be
        re-evaluated without a resource write.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/products/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result
      operationId: companyProductsComplianceShow
      description: |
        Returns the most recent compliance scan result for the Product. A
        fresh scan is queued automatically on every company create +
        update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan
      operationId: companyProductsComplianceScan
      description: |
        Queues a fresh compliance scan. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/compliance` when the
        job completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the product.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/enrollment-packs:
    get:
      tags: [storefront]
      summary: Public Enrollment Packs catalog
      operationId: storefrontEnrollmentPacksIndex
      description: |
        Unauthenticated. Company is resolved from the request subdomain.
        Returns ONLY live packs (active + non-discarded), country- and
        language-eligible — non-public rows never leak. The heavy pack
        contents are on the show response, not the catalog card.
      security: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of live enrollment packs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollmentPackListResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/enrollment-packs/{slug}:
    get:
      tags: [storefront]
      summary: Public Enrollment Pack by slug
      operationId: storefrontEnrollmentPackShow
      description: |
        Unauthenticated. Resolves by slug (the canonical public identifier;
        matches `canonical_url`). Show is more permissive than the catalog:
        an inactive (draft) pack still resolves by direct slug (rendered
        noindex); only discarded packs return 404, never 403. Includes the
        full pack contents (membership + subscription products, agreements).
      security: []
      parameters:
        - name: slug
          description: The URL slug of the enrollment pack.
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Enrollment Pack.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollmentPackShowResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/enrollment-packs:
    get:
      tags: [company]
      summary: Company Enrollment Packs index
      operationId: companyEnrollmentPacksIndex
      description: |
        Token-authenticated; requires `storefront.view` (legacy
        `enrollment_packs.view` accepted during the migration). Returns
        EVERY pack for the calling company regardless of lifecycle, so
        admins can manage inactive (draft) rows. Same cursor / filter /
        sort / `q` contract as the public catalog; adds `filter[status]`
        (published/draft) and `filter[active]`.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[status]
          in: query
          required: false
          description: |
            One of `draft`, `scheduled`, or `published`. Restricts the index to a
            single lifecycle state. Company-surface only. Matches the STORED
            `status` column, not the read-time-resolved status: a past-due
            `scheduled` row (its `publish_at` has passed, so it renders as
            `published`) is matched by `filter[status]=scheduled`, NOT by
            `filter[status]=published`.
          schema:
            type: string
            enum: [draft, scheduled, published]
        - $ref: "#/components/parameters/FilterActive"
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of enrollment packs.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollmentPackListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    post:
      tags: [company]
      summary: Create an Enrollment Pack
      operationId: companyEnrollmentPacksCreate
      description: |
        Token-authenticated; requires `storefront.update`. A pack must
        bundle at least one enrollable product. Triggers fresh Lighthouse +
        compliance scans automatically.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrollmentPackCreateRequest"
            examples:
              minimal:
                summary: Minimal create
                value:
                  enrollment_pack:
                    title: Primex Welcome Pack
                    description: Everything a new member needs to get started.
                    member_enrollable_products_attributes:
                      - variant_id: 7001
              full:
                summary: Full create
                value:
                  enrollment_pack:
                    title: Primex Welcome Pack
                    description: Everything a new member needs to get started.
                    status: published
                    enrollment_fee: 29.0
                    additional_volume: 100
                    membership_after_one_month: false
                    membership_optional: true
                    country_isos: [GB, US]
                    member_enrollable_products_attributes:
                      - variant_id: 7001
                    subscription_enrollable_products_attributes:
                      - variant_id: 7002
                        subscription_plan_id: 9001
                    enrollment_pack_agreements:
                      - agreement_id: 31
                        required: true
                        position: 0
                    search_engine_optimizer_attributes:
                      title: Join Primex — Welcome Pack
                      description: Start your Primex membership today.
                      block_crawler: false
      responses:
        "201":
          description: The created Enrollment Pack.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollmentPackShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/enrollment-packs/{id}:
    get:
      tags: [company]
      summary: Company Enrollment Pack show
      operationId: companyEnrollmentPacksShow
      description: |
        Token-authenticated; resolves by id (the canonical company identifier).
        Returns the Enrollment Pack in any lifecycle state.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Enrollment Pack.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollmentPackShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [company]
      summary: Update an Enrollment Pack
      operationId: companyEnrollmentPacksUpdate
      description: |
        Token-authenticated; requires `storefront.update`. Only the keys
        sent are updated. Triggers fresh Lighthouse + compliance scans.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrollmentPackUpdateRequest"
            example:
              enrollment_pack:
                status: draft
                country_isos: [US]
      responses:
        "200":
          description: The updated Enrollment Pack.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrollmentPackShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      tags: [company]
      summary: Destroy an Enrollment Pack
      operationId: companyEnrollmentPacksDestroy
      description: |
        Token-authenticated. Soft-deletes via Discard (sets `discarded_at`)
        and detaches the pack from open carts.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted (soft).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/enrollment-packs/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for an Enrollment Pack
      operationId: companyEnrollmentPacksShares
      description: |
        Read-only aggregate share statistics for the Enrollment Pack. Uses the
        shared shares response shape common to the storefront resources.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/enrollment-packs/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result
      operationId: companyEnrollmentPacksLighthouseShow
      description: |
        Returns the most recent successful Lighthouse scan for the Enrollment
        Pack (scans persist only on success). A fresh scan is auto-queued on
        every company create and update, but it runs asynchronously — a
        failed, rate-limited, or recently-run rescan writes no new record,
        so this endpoint may return the prior scan with HTTP 200. `POST
        .../lighthouse` requests a rescan (it always returns 202) but
        cannot guarantee a newer score — the queued scan is skipped
        silently when one ran in the last ~5 minutes, the scanner is
        rate-limited, or its circuit breaker is open.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan
      operationId: companyEnrollmentPacksLighthouseScan
      description: |
        Queues a fresh Lighthouse scan for the Enrollment Pack. Returns
        immediately with `202 Accepted`; the new result lands at `GET
        :id/lighthouse` once the job completes. Manual triggers are rarely needed
        — every company create and update auto-queues a scan — but are useful when
        an out-of-band change (DNS, CDN, theme deploy) should be re-scored without
        a resource write.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/enrollment-packs/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result
      operationId: companyEnrollmentPacksComplianceShow
      description: |
        Returns the most recent compliance scan result for the Enrollment Pack. A
        fresh scan is queued automatically on every company create + update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan
      operationId: companyEnrollmentPacksComplianceScan
      description: |
        Queues a fresh compliance scan for the Enrollment Pack. Returns
        immediately with `202 Accepted`; the new result lands at `GET
        :id/compliance` once the job completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the enrollment pack.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v202604/posts:
    get:
      tags: [storefront]
      summary: Public Posts (blog) catalog
      operationId: storefrontPostsIndex
      description: |
        Unauthenticated. Company is resolved from the request subdomain.
        Returns ONLY live posts (active + resolved-published,
        country-eligible, language-eligible, not discarded).

        Posts are presented on the storefront as the blog — the
        canonical storefront URLs use `/home/blog` and
        `/home/blog/:slug`, even though the API resource is `posts`.
      security: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[category_id]
          in: query
          required: false
          description: Restrict the catalog to posts filed under one Category.
          schema:
            type: integer
        - name: filter[collection_id]
          in: query
          required: false
          description: Restrict the catalog to posts appearing in one Collection.
          schema:
            type: integer
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default
            sort is newest blog posts first (`post_date DESC NULLS
            LAST`).
          schema:
            type: string
            enum: [post_date, -post_date, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of live posts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostListResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/posts/{slug}:
    get:
      tags: [storefront]
      summary: Public Post by slug
      operationId: storefrontPostShow
      description: |
        Unauthenticated. Resolves by slug. Returns 404 for archived or
        discarded posts; draft / scheduled / inactive posts resolve
        (noindex at the theme layer).
      security: []
      parameters:
        - name: slug
          description: The URL slug of the post.
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Post.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostShowResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/posts:
    get:
      tags: [company]
      summary: Company Posts index
      operationId: companyPostsIndex
      description: |
        Token-authenticated; returns every Post regardless of lifecycle
        (admins manage drafts / scheduled / archived rows). Excludes
        discarded rows by default. Accepts the same params as the
        public catalog plus the admin-only `filter[status]` and
        `filter[active]`.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[status]
          in: query
          required: false
          description: |
            One of `draft`, `scheduled`, `published`, or `archived`. Restricts the
            index to a single lifecycle state. Company-surface only. Matches the
            STORED `status` column, not the read-time-resolved status: a past-due
            `scheduled` row (its `publish_at` has passed, so it renders as
            `published`) is matched by `filter[status]=scheduled`, NOT by
            `filter[status]=published`.
          schema:
            type: string
            enum: [draft, scheduled, published, archived]
        - $ref: "#/components/parameters/FilterActive"
        - name: filter[category_id]
          in: query
          required: false
          description: |
            Restrict the catalog to posts filed under one Category.
          schema:
            type: integer
        - name: filter[collection_id]
          in: query
          required: false
          description: |
            Restrict the catalog to posts appearing in one Collection.
          schema:
            type: integer
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default sort is newest
            blog posts first (`post_date DESC NULLS LAST`).
          schema:
            type: string
            enum: [post_date, -post_date, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of posts (every lifecycle state).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostCompanyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    post:
      tags: [company]
      summary: Create a Post
      operationId: companyPostsCreate
      description: |
        Token-authenticated; requires `storefront.update` (or legacy
        `posts.update` during the migration).

        Triggers fresh Lighthouse + compliance scans automatically.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PostCreateRequest"
            examples:
              minimal:
                summary: Minimal create
                value:
                  post:
                    title: How We Shipped the Summer Line
              full:
                summary: Full create — scheduled post with editorial tail
                value:
                  post:
                    title: How We Shipped the Summer Line
                    description: A behind-the-scenes look at three months of work.
                    summary: Three months of work compressed into 1,400 words.
                    slug: how-we-shipped-the-summer-line
                    custom_slug: true
                    active: true
                    status: scheduled
                    publish_at: "2026-07-01T08:00:00Z"
                    post_date: "2026-07-01"
                    post_author: Dwight Schrute
                    category_id: 4821
                    collection_ids: [5102]
                    country_isos: [GB, US]
                    search_engine_optimizer_attributes:
                      title: How We Shipped the Summer Line | Acme Blog
                      description: Three months of work compressed into 1,400 words.
                      image_url: https://ik.imagekit.io/fluid/seo/post-7305.png
                      block_crawler: false
      responses:
        "201":
          description: The created Post.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/posts/{id}:
    get:
      tags: [company]
      summary: Company Post show
      operationId: companyPostsShow
      description: |
        Token-authenticated; resolves by id (the canonical company identifier).
        Returns the Post in any lifecycle state.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Post.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [company]
      summary: Update a Post
      operationId: companyPostsUpdate
      description: |
        Token-authenticated. PATCH semantics — only keys present in
        the payload are updated. Triggers fresh Lighthouse +
        compliance scans automatically.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PostUpdateRequest"
      responses:
        "200":
          description: The updated Post.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      tags: [company]
      summary: Destroy a Post (soft-delete)
      operationId: companyPostsDestroy
      description: |
        Token-authenticated. SOFT-delete via Discard — sets
        `discarded_at`, does not hard-delete. The post is excluded
        from every public surface and the company index (kept-scope
        filter) but remains in the DB. Fires a `posts.deleted`
        webhook post-commit.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted (soft).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/posts/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for a Post
      operationId: companyPostsShares
      description: |
        Read-only aggregate share statistics for the Post. Uses the shared shares
        response shape common to the storefront resources.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/posts/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result
      operationId: companyPostsLighthouseShow
      description: |
        Returns the most recent Lighthouse scan for the Post,
        including the full Lighthouse JSON `result` blob. When no
        scan exists, queues one and returns 202 with `scan_status`.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan
      operationId: companyPostsLighthouseScan
      description: |
        Queues a fresh Lighthouse scan for the Post. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/lighthouse` once the job
        completes. Manual triggers are rarely needed — every company create and
        update auto-queues a scan — but are useful when an out-of-band change
        (DNS, CDN, theme deploy) should be re-scored without a resource write.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/posts/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result
      operationId: companyPostsComplianceShow
      description: |
        Returns the most recent compliance scan result for the Post. A fresh scan
        is queued automatically on every company create + update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan
      operationId: companyPostsComplianceScan
      description: |
        Queues a fresh compliance scan for the Post. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/compliance` once the job
        completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the post.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v202604/media:
    get:
      tags: [storefront]
      summary: Public Media catalog
      operationId: storefrontMediaIndex
      description: |
        Unauthenticated. Company is resolved from the request subdomain.
        Returns ONLY live media (active + resolved-published + non-discarded),
        country- and language-eligible — non-public rows never leak. A
        future-scheduled medium appears automatically once its `publish_at`
        passes.
      security: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[media_type]
          in: query
          required: false
          description: |
            Restrict to one media type: `share`, `training`, `update`, or `upload`.
          schema:
            type: string
            enum: [share, training, update, upload]
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of live media.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediumListResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/media/{slug}:
    get:
      tags: [storefront]
      summary: Public Medium by slug
      operationId: storefrontMediumShow
      description: |
        Unauthenticated. Resolves by slug (the canonical public identifier;
        matches `canonical_url`). Show is more permissive than the catalog:
        a draft (inactive) or future-scheduled medium still resolves by
        direct slug (rendered noindex); only discarded media return 404,
        never 403.
      security: []
      parameters:
        - name: slug
          description: The URL slug of the media.
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Medium.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediumShowResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/media:
    get:
      tags: [company]
      summary: Company Media index
      operationId: companyMediaIndex
      description: |
        Token-authenticated; requires `storefront.view` (legacy `media.view`
        accepted during the migration). Returns EVERY kept medium for the
        calling company regardless of lifecycle, so admins can manage draft /
        scheduled rows. Same cursor / filter / sort / `q` contract as the
        public catalog; adds `filter[status]`, `filter[active]`, and
        `filter[visibility]`.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[media_type]
          in: query
          required: false
          description: |
            Restrict to one media type: `share`, `training`, `update`, or `upload`.
          schema:
            type: string
            enum: [share, training, update, upload]
        - name: filter[status]
          in: query
          required: false
          description: |
            One of `draft`, `scheduled`, or `published`. Restricts the index to a
            single lifecycle state. Company-surface only. Matches the STORED
            `status` column, not the read-time-resolved status: a past-due
            `scheduled` row (its `publish_at` has passed, so it renders as
            `published`) is matched by `filter[status]=scheduled`, NOT by
            `filter[status]=published`.
          schema:
            type: string
            enum: [draft, scheduled, published]
        - $ref: "#/components/parameters/FilterActive"
        - name: filter[visibility]
          in: query
          required: false
          description: |
            Company-surface only. Restrict to media with `open` or `restricted`
            visibility.
          schema:
            type: string
            enum: [open, restricted]
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of media.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediumCompanyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    post:
      tags: [company]
      summary: Create a Medium
      operationId: companyMediaCreate
      description: |
        Token-authenticated; requires `storefront.update`. Triggers fresh
        Lighthouse + compliance scans automatically.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MediumCreateRequest"
            examples:
              minimal:
                summary: Minimal create
                value:
                  medium:
                    title: Primex Wellness Overview
                    video_url: https://cdn.fluid.app/media/60311.mp4
              full:
                summary: Full create
                value:
                  medium:
                    title: Primex Wellness Overview
                    description: A short overview of the Primex wellness range.
                    status: published
                    media_type: share
                    kind: video
                    visibility: open
                    duration: 312
                    video_url: https://cdn.fluid.app/media/60311.mp4
                    image_url: https://cdn.fluid.app/media/60311.png
                    country_isos: [GB, US]
                    search_engine_optimizer_attributes:
                      title: Primex Wellness Overview
                      description: Watch the Primex wellness overview.
                      block_crawler: false
      responses:
        "201":
          description: The created Medium.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediumCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/media/{id}:
    get:
      tags: [company]
      summary: Company Medium show
      operationId: companyMediumShow
      description: |
        Token-authenticated; resolves by id (the canonical company identifier).
        Returns the Medium in any lifecycle state.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Medium.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediumCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [company]
      summary: Update a Medium
      operationId: companyMediumUpdate
      description: |
        Token-authenticated; requires `storefront.update`. Only the keys
        sent are updated. Triggers fresh Lighthouse + compliance scans.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MediumUpdateRequest"
            example:
              medium:
                status: draft
                country_isos: [US]
      responses:
        "200":
          description: The updated Medium.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MediumCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      tags: [company]
      summary: Destroy a Medium
      operationId: companyMediumDestroy
      description: |
        Token-authenticated. Soft-deletes via Discard (sets `discarded_at`).
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted (soft).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/media/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for a Medium
      operationId: companyMediumShares
      description: |
        Read-only aggregate share statistics for the Medium. Uses the shared
        shares response shape common to the storefront resources.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/media/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result
      operationId: companyMediumLighthouseShow
      description: |
        Returns the most recent successful Lighthouse scan for the Medium
        (scans persist only on success). A fresh scan is auto-queued on
        every company create and update, but it runs asynchronously — a
        failed, rate-limited, or recently-run rescan writes no new record,
        so this endpoint may return the prior scan with HTTP 200. `POST
        .../lighthouse` requests a rescan (it always returns 202) but
        cannot guarantee a newer score — the queued scan is skipped
        silently when one ran in the last ~5 minutes, the scanner is
        rate-limited, or its circuit breaker is open.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan
      operationId: companyMediumLighthouseScan
      description: |
        Queues a fresh Lighthouse scan for the Medium. Returns immediately with
        `202 Accepted`; the new result lands at `GET :id/lighthouse` once the job
        completes. Manual triggers are rarely needed — every company create and
        update auto-queues a scan — but are useful when an out-of-band change
        (DNS, CDN, theme deploy) should be re-scored without a resource write.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/media/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result
      operationId: companyMediumComplianceShow
      description: |
        Returns the most recent compliance scan result for the Medium. A fresh
        scan is queued automatically on every company create + update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan
      operationId: companyMediumComplianceScan
      description: |
        Queues a fresh compliance scan for the Medium. Returns immediately with
        `202 Accepted`; the new result lands at `GET :id/compliance` once the job
        completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the media.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/pages:
    get:
      tags: [storefront]
      summary: Public Pages catalog
      operationId: storefrontPagesIndex
      description: |
        Unauthenticated. Company is resolved from the request subdomain.
        Returns ONLY live pages (publish toggle on + resolved-published +
        non-discarded), country- and language-eligible — non-public rows
        never leak. A future-scheduled page appears automatically once its
        `publish_at` passes. Pages are a METADATA surface — the rendered
        content lives on the themed storefront page at `canonical_url`.
        The same payload is served on the storefront domain at
        `/home/pages.json` for AEO crawlers.
      security: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of live storefront pages.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PageListResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/pages/{slug}:
    get:
      tags: [storefront]
      summary: Public Page by slug
      operationId: storefrontPageShow
      description: |
        Unauthenticated. Resolves by slug (the canonical public identifier;
        matches `canonical_url`). Show is more permissive than the catalog:
        a draft or future-scheduled page still resolves by direct slug
        (rendered noindex on the HTML surface); only discarded pages return
        404, never 403. The same payload is served on the storefront domain
        at `/home/pages/{slug}.json`.
      security: []
      parameters:
        - name: slug
          description: The URL slug of the page.
          in: path
          required: true
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PageShowResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/pages:
    get:
      tags: [company]
      summary: Company Pages index
      operationId: companyPagesIndex
      description: |
        Token-authenticated; requires `storefront.view` (legacy `pages.view`
        accepted during the migration). Returns EVERY kept page for the
        calling company regardless of lifecycle, so admins can manage draft /
        scheduled rows. Same cursor / filter / sort / `q` contract as the
        public catalog; adds `filter[status]` and `filter[active]`.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[status]
          in: query
          required: false
          description: |
            One of `draft`, `scheduled`, or `published`. Restricts the index to a
            single lifecycle state. Company-surface only. Matches the STORED
            `status` column, not the read-time-resolved status: a past-due
            `scheduled` row (its `publish_at` has passed, so it renders as
            `published`) is matched by `filter[status]=scheduled`, NOT by
            `filter[status]=published`.
          schema:
            type: string
            enum: [draft, scheduled, published]
        - $ref: "#/components/parameters/FilterActive"
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of pages.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PageCompanyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    post:
      tags: [company]
      summary: Create a Page
      operationId: companyPagesCreate
      description: |
        Token-authenticated; requires `storefront.update`. Triggers fresh
        Lighthouse + compliance scans automatically and fires the
        `pages.created` webhook. With `lang`, translated fields (`title`,
        `description`, `image_url`) are written for that locale.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PageCreateRequest"
            examples:
              minimal:
                summary: Minimal create
                value:
                  page:
                    title: Our Story
              full:
                summary: Full create
                value:
                  page:
                    title: Our Story
                    description: How Primex went from a Provo garage to a global wellness brand.
                    status: published
                    slug: our-story
                    custom_slug: true
                    country_isos: [GB, US]
                    metafields_attributes:
                      - namespace: custom
                        key: hero_variant
                        value: full-bleed
                        value_type: single_line_text_field
                    search_engine_optimizer_attributes:
                      title: Our Story — Primex
                      description: How Primex went from a Provo garage to a global wellness brand.
                      block_crawler: false
      responses:
        "201":
          description: The created Page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PageCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/pages/{id}:
    get:
      tags: [company]
      summary: Company Page show
      operationId: companyPageShow
      description: |
        Token-authenticated; resolves by id (the canonical company identifier).
        Returns the Page in any lifecycle state.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Page, including its admin metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PageCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [company]
      summary: Update a Page
      operationId: companyPageUpdate
      description: |
        Token-authenticated; requires `storefront.update`. Only the keys sent
        are updated. Triggers fresh Lighthouse + compliance scans and fires
        the `pages.updated` webhook. With `lang`, translated fields are
        written for that locale only.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PageUpdateRequest"
            example:
              page:
                status: scheduled
                publish_at: "2026-08-01T09:00:00Z"
      responses:
        "200":
          description: The updated Page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PageCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      tags: [company]
      summary: Destroy a Page
      operationId: companyPageDestroy
      description: |
        Token-authenticated. Soft-deletes via Discard (sets `discarded_at`)
        and fires the `pages.deleted` webhook.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted (soft).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/pages/{id}/clone:
    post:
      tags: [company]
      summary: Clone a Page
      operationId: companyPageClone
      description: |
        Token-authenticated; requires `storefront.update`. Deep-copies the
        page together with its theme template. The clone lands unpublished
        (`draft`) with a fresh auto-generated slug and title suffix, and
        fires the `pages.created` webhook.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "201":
          description: The cloned Page.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PageCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/pages/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for a Page
      operationId: companyPageShares
      description: |
        Read-only aggregate share statistics for the Page. Uses the shared shares
        response shape common to the storefront resources.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/pages/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result
      operationId: companyPageLighthouseShow
      description: |
        Returns the most recent successful Lighthouse scan for the Page
        (scans persist only on success). A fresh scan is auto-queued on
        every company create and update, but it runs asynchronously — a
        failed, rate-limited, or recently-run rescan writes no new record,
        so this endpoint may return the prior scan with HTTP 200. `POST
        .../lighthouse` requests a rescan (it always returns 202) but
        cannot guarantee a newer score — the queued scan is skipped
        silently when one ran in the last ~5 minutes, the scanner is
        rate-limited, or its circuit breaker is open.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan
      operationId: companyPageLighthouseScan
      description: |
        Queues a fresh Lighthouse scan for the Page. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/lighthouse` once the job
        completes. Manual triggers are rarely needed — every company create and
        update auto-queues a scan — but are useful when an out-of-band change
        (DNS, CDN, theme deploy) should be re-scored without a resource write.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/pages/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result
      operationId: companyPageComplianceShow
      description: |
        Returns the most recent compliance scan result for the Page. A fresh scan
        is queued automatically on every company create + update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan
      operationId: companyPageComplianceScan
      description: |
        Queues a fresh compliance scan for the Page. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/compliance` once the job
        completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the page.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/playlists:
    get:
      tags: [storefront]
      summary: List playlists (public storefront catalog)
      operationId: storefrontPlaylistsIndex
      description: |
        PUBLIC endpoint — NO authentication required. Company is resolved from the
        request subdomain. Returns only live playlists: `active == true` (inactive/
        draft playlists are excluded). Cursor-paginated. Supports `lang`, `q`,
        `filter[country]`, `filter[language]`, and `sort`.

        The same payload is available at `/home/libraries.json` on the storefront
        domain for AEO crawlers. `canonical_url` always points at the
        `/home/libraries/:slug` path family on the primary store domain.
      security: []
      parameters:
        - name: lang
          in: query
          schema: { type: string }
          description: ISO locale code. Defaults to the store's default locale. Falls back to `en` for missing translations.
          example: en
        - name: q
          in: query
          schema: { type: string }
          description: Free-text search over title and description (Elasticsearch).
        - name: page[cursor]
          in: query
          schema: { type: string }
          description: Opaque Rotulus cursor from the previous response's `meta.pagination.next_cursor`.
        - name: page[limit]
          in: query
          description: |
            Page size. Default 25, max 100; a larger value returns 422.
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: filter[country]
          in: query
          schema: { type: string }
          description: ISO-2 country code. Returns unrestricted playlists plus those restricted to this country.
          example: GB
        - name: filter[language]
          in: query
          schema: { type: string }
          description: ISO locale. Restricts to playlists translated in this locale.
        - name: sort
          in: query
          schema:
            type: string
            enum: [title, -title, created_at, -created_at]
          description: Sort axis. Prefix `-` for descending. Default is `-created_at`.
      responses:
        "200":
          description: A page of live playlists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlaylistListResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/playlists/{slug}:
    get:
      tags: [storefront]
      summary: Show a playlist (public, by slug)
      operationId: storefrontPlaylistShow
      description: |
        PUBLIC endpoint — NO authentication required. Returns any non-discarded
        playlist by slug: `draft`, `inactive`, and live playlists all resolve
        (so preview / pre-launch links work). Only discarded playlists return 404.
        Non-live playlists render with `noindex` metadata. The response uses the
        `:detail` view — includes `library_items` — not the lean catalog card.
      security: []
      parameters:
        - name: slug
          description: The URL slug of the playlist.
          in: path
          required: true
          schema: { type: string }
          example: primex-wellness-playlist
        - name: lang
          in: query
          schema: { type: string }
          description: ISO locale for the response. Falls back to `en`.
      responses:
        "200":
          description: Playlist detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlaylistShowResponse"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/playlists:
    get:
      tags: [company]
      summary: List playlists (company, authenticated)
      operationId: companyPlaylistsIndex
      description: |
        Returns ALL company playlists regardless of lifecycle (including inactive/
        draft). Use `filter[status]` or `filter[active]` to narrow. Auth required.
        After each create or update, both Lighthouse and compliance scans are queued
        automatically — a brief `scan_status: pending` on `GET :id/lighthouse` and
        `:id/compliance` immediately after a write is expected.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - name: page[cursor]
          in: query
          description: |
            Opaque Rotulus cursor from the previous response's
            `meta.pagination.next_cursor`.
          schema: { type: string }
        - name: page[limit]
          in: query
          description: |
            Page size. Default 25, max 100; a larger value returns 422.
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: filter[status]
          in: query
          description: |
            One of `draft`, `scheduled`, or `published`. Restricts the index to a
            single lifecycle state. Company-surface only. Matches the STORED
            `status` column, not the read-time-resolved status: a past-due
            `scheduled` row (its `publish_at` has passed, so it renders as
            `published`) is matched by `filter[status]=scheduled`, NOT by
            `filter[status]=published`.
          schema: { type: string, enum: [draft, scheduled, published] }
        - $ref: "#/components/parameters/FilterActive"
        - name: filter[country]
          in: query
          description: |
            ISO-2 country code. Returns unrestricted playlists plus those restricted
            to this country.
          schema: { type: string }
        - name: filter[language]
          in: query
          description: |
            ISO locale. Restricts to playlists translated in this locale.
          schema: { type: string }
        - name: sort
          in: query
          description: |
            Sort axis. Prefix `-` for descending. Default is `-created_at`.
          schema: { type: string, enum: [title, -title, created_at, -created_at] }
      responses:
        "200":
          description: Paginated list of company playlists.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlaylistCompanyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    post:
      tags: [company]
      summary: Create a playlist
      operationId: companyPlaylistsCreate
      description: |
        Creates a new playlist. `status: published` sets `active: true`; `status:
        draft` sets `active: false`. Send `slug` + `custom_slug: true` to pin a
        permanent slug; without `custom_slug: true` the slug regenerates on title
        change. After creation, Lighthouse and compliance scans are queued
        automatically. With `lang`, the translatable fields (`title`,
        `description`) are written for that locale; omitting `lang` writes the
        default locale.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlaylistCreateRequest"
            example:
              library:
                title: Primex Wellness Playlist
                description: A curated collection for Primex members.
                status: published
                kind: manual
                country_isos: [GB, US]
      responses:
        "201":
          description: Playlist created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlaylistCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/playlists/{id}:
    get:
      tags: [company]
      summary: Show a playlist (company, by id)
      operationId: companyPlaylistShow
      description: |
        With `lang`, translated fields (`title`, `description`) render in that
        locale — use it to read a specific translation before editing it.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: Playlist detail.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlaylistCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [company]
      summary: Update a playlist
      operationId: companyPlaylistUpdate
      description: |
        Partial update — only keys present in the payload are changed. After the
        update, Lighthouse and compliance scans are queued automatically. With
        `lang`, the translatable fields (`title`, `description`) are written
        for that locale only — the default-locale copy is untouched; omitting
        `lang` writes the default locale.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PlaylistUpdateRequest"
      responses:
        "200":
          description: Updated playlist.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PlaylistCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      tags: [company]
      summary: Soft-delete a playlist
      operationId: companyPlaylistDestroy
      description: Soft-deletes (discards) the playlist. The slug becomes available for reuse immediately.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
      responses:
        "204":
          description: Playlist deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/playlists/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for a playlist
      operationId: companyPlaylistSharesShow
      description: |
        Read-only aggregate share statistics for the playlist. Uses the shared
        shares response shape common to the storefront resources.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/playlists/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result for a playlist
      operationId: companyPlaylistLighthouseShow
      description: |
        Returns the most recent successful Lighthouse scan for the Playlist
        (scans persist only on success). A fresh scan is auto-queued on
        every company create and update, but it runs asynchronously — a
        failed, rate-limited, or recently-run rescan writes no new record,
        so this endpoint may return the prior scan with HTTP 200. `POST
        .../lighthouse` requests a rescan (it always returns 202) but
        cannot guarantee a newer score — the queued scan is skipped
        silently when one ran in the last ~5 minutes, the scanner is
        rate-limited, or its circuit breaker is open.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan for a playlist
      operationId: companyPlaylistLighthouseScan
      description: |
        Queues a fresh Lighthouse scan for the playlist. Returns immediately with
        `202 Accepted`; the new result lands at `GET :id/lighthouse` once the job
        completes. Manual triggers are rarely needed — every company create and
        update auto-queues a scan — but are useful when an out-of-band change
        (DNS, CDN, theme deploy) should be re-scored without a resource write.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/playlists/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result for a playlist
      operationId: companyPlaylistComplianceShow
      description: |
        Returns the most recent compliance scan result for the playlist. A fresh
        scan is queued automatically on every company create + update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan for a playlist
      operationId: companyPlaylistComplianceScan
      description: |
        Queues a fresh compliance scan for the playlist. Returns immediately with
        `202 Accepted`; the new result lands at `GET :id/compliance` once the job
        completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the playlist.
          in: path
          required: true
          schema: { type: integer }
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/collections:
    get:
      tags: [storefront]
      summary: Public Collections catalog
      operationId: storefrontCollectionsIndex
      description: |
        Unauthenticated. Company is resolved from the request subdomain.
        Returns ONLY live collections (active + resolved-published,
        country-eligible, language-eligible) — non-public rows never
        leak.
      security: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of live collections.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectionListResponse"
              example:
                status: 200
                collections:
                  - id: 5102
                    slug: wellness-essentials
                    title: Wellness Essentials
                    description: Hand-picked supplements and recovery gear.
                    image_url: https://cdn.fluid.app/collections/5102.png
                    canonical_url: https://acme.fluid.app/home/collections/wellness-essentials
                    images:
                      thumb:  { url: "https://ik.imagekit.io/fluidapp/tr:w-200,c-at_max,f-auto,q-70/collections/5102.png",  width: 200 }
                      medium: { url: "https://ik.imagekit.io/fluidapp/tr:w-600,c-at_max,f-auto,q-75/collections/5102.png",  width: 600 }
                      large:  { url: "https://ik.imagekit.io/fluidapp/tr:w-1200,c-at_max,f-auto,q-80/collections/5102.png", width: 1200 }
                    active: true
                    status: published
                    publish_at: null
                    seo:
                      title: Wellness Essentials — Curated Picks
                      description: Hand-picked supplements and recovery gear.
                      image_url: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/9302.png
                      block_crawler: false
                    metafields: []
                    countries: [GB, US]
                    languages: [en, fr]
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                  pagination:
                    cursor: null
                    next_cursor: eyJpZCI6NTEwM30
                    prev_cursor: null
                    limit: 25
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/collections/{slug}:
    get:
      tags: [storefront]
      summary: Public Collection by slug
      operationId: storefrontCollectionShow
      description: |
        Unauthenticated. Resolves by slug (the canonical public
        identifier; matches `canonical_url`). Returns 404 ONLY for an
        `archived` row — draft / scheduled / inactive collections
        resolve here (so preview + pre-launch links load) and render
        noindex at the theme layer.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          description: |
            Canonical public slug of the collection; matches the row's
            `canonical_url`.
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Collection.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectionShowResponse"
              example:
                status: 200
                meta:
                  request_id: 7f3c2a90-1c2b-4d5e-9aaa-112233445566
                  timestamp: "2026-07-15T12:34:56Z"
                collection:
                  id: 5102
                  slug: wellness-essentials
                  title: Wellness Essentials
                  description: Hand-picked supplements and recovery gear.
                  image_url: https://cdn.fluid.app/collections/5102.png
                  canonical_url: https://acme.fluid.app/home/collections/wellness-essentials
                  images:
                    thumb:  { url: "https://ik.imagekit.io/fluidapp/tr:w-200,c-at_max,f-auto,q-70/collections/5102.png",  width: 200 }
                    medium: { url: "https://ik.imagekit.io/fluidapp/tr:w-600,c-at_max,f-auto,q-75/collections/5102.png",  width: 600 }
                    large:  { url: "https://ik.imagekit.io/fluidapp/tr:w-1200,c-at_max,f-auto,q-80/collections/5102.png", width: 1200 }
                  active: true
                  status: published
                  publish_at: null
                  seo:
                    id: 9302
                    title: Wellness Essentials — Curated Picks
                    description: Hand-picked supplements and recovery gear.
                    image_url: https://ik.imagekit.io/fluidapp/tr:w-1200,h-630,c-maintain_ratio,fo-auto,f-auto,q-80/seo/9302.png
                    block_crawler: false
                  metafields: []
                  countries: [GB, US]
                  languages: [en, fr]
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/collections/{slug}/products:
    get:
      tags: [storefront]
      summary: Products in a Collection (public)
      operationId: storefrontCollectionProducts
      description: |
        PUBLIC endpoint — NO authentication required. Company is resolved
        from the request subdomain.

        The products catalog scoped to one collection, not a second catalog:
        the response body, filters, sorting, search, country-relative
        pricing, and cursor pagination are identical to
        `GET /api/v202604/products`. Returns ONLY live products (active +
        resolved-published) with an active variant in the requested
        `country`.

        The collection in the path always wins — a `filter[collection_ids]` in the
        query string cannot widen the listing beyond it. Returns 404 when
        no live collection matches the slug.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          description: The collection's slug, as returned in its `slug` field.
          schema:
            type: string
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - name: filter[availability]
          in: query
          required: false
          description: |
            Stock filter. `in_stock` restricts to purchasable products;
            `all` (default) imposes no stock constraint.
          schema:
            type: string
            enum: [all, in_stock]
            default: all
        - name: filter[bundle]
          in: query
          required: false
          description: |
            `true` returns only bundle products; `false` returns only
            non-bundle products.
          schema:
            type: boolean
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default
            `priority` then `id`.
          schema:
            type: string
            enum: [priority, -priority, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of live products in this collection, with country-relative pricing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductListResponse"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
  /api/v202604/company/collections:
    get:
      tags: [company]
      summary: Company Collections index
      operationId: companyCollectionsIndex
      description: |
        Token-authenticated; returns every Collection regardless of
        lifecycle (admins manage drafts / scheduled / archived rows).
        Accepts the same params as the public catalog, plus the admin
        filters `filter[status]` and `filter[active]`.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - $ref: "#/components/parameters/FilterCountry"
        - $ref: "#/components/parameters/FilterLanguage"
        - name: filter[status]
          in: query
          required: false
          description: |
            One of `draft`, `scheduled`, `published`, or `archived`. Restricts the
            index to a single lifecycle state. Company-surface only. Matches the
            STORED `status` column, not the read-time-resolved status: a past-due
            `scheduled` row (its `publish_at` has passed, so it renders as
            `published`) is matched by `filter[status]=scheduled`, NOT by
            `filter[status]=published`.
          schema:
            type: string
            enum: [draft, scheduled, published, archived]
        - $ref: "#/components/parameters/FilterActive"
        - $ref: "#/components/parameters/SortNoPosition"
      responses:
        "200":
          description: A page of collections (every lifecycle state).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectionCompanyListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    post:
      tags: [company]
      summary: Create a Collection
      operationId: companyCollectionsCreate
      description: |
        Token-authenticated; requires `storefront.update` (or legacy
        `collections.update` during the migration).

        Triggers fresh Lighthouse + compliance scans automatically
        — a subsequent `GET :id/lighthouse` / `:id/compliance`
        returns `scan_status: pending` while the queued scans run.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CollectionCreateRequest"
            examples:
              minimal:
                summary: Minimal create
                value:
                  collection:
                    title: Wellness Essentials
              full:
                summary: Full create
                value:
                  collection:
                    title: Wellness Essentials
                    description: Hand-picked supplements and recovery gear.
                    slug: wellness-essentials
                    custom_slug: true
                    active: true
                    status: scheduled
                    publish_at: "2026-07-01T08:00:00Z"
                    country_isos: [GB, US]
                    metafields_attributes:
                      - namespace: custom
                        key: theme_accent
                        value: "#1ABC9C"
                        value_type: single_line_text_field
                    search_engine_optimizer_attributes:
                      title: Wellness Essentials — Curated Picks
                      description: Hand-picked supplements and recovery gear.
                      image_url: https://ik.imagekit.io/fluid/seo/9302.png
                      block_crawler: false
      responses:
        "201":
          description: The created Collection.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectionCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"

  /api/v202604/company/collections/{id}:
    get:
      tags: [company]
      summary: Company Collection show
      operationId: companyCollectionsShow
      description: |
        Token-authenticated; resolves by id. Returns any Collection
        regardless of lifecycle.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          in: path
          required: true
          description: |
            Numeric id of the collection — the canonical company identifier.
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      responses:
        "200":
          description: The Collection.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectionCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [company]
      summary: Update a Collection
      operationId: companyCollectionsUpdate
      description: |
        Token-authenticated. PATCH semantics — only keys present in
        the payload are updated. Triggers fresh Lighthouse + compliance
        scans automatically.
      security:
        - bearer_auth: []
      parameters:
        - $ref: "#/components/parameters/LangWrite"
        - name: id
          description: The unique identifier of the collection.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CollectionUpdateRequest"
            examples:
              publish:
                summary: Publish (make live)
                value:
                  collection:
                    status: published
                    active: true
              setCountries:
                summary: Restrict country availability
                value:
                  collection:
                    country_isos: [GB, US]
              clearCountries:
                summary: Clear restrictions (available everywhere the company sells)
                value:
                  collection:
                    country_isos: []
      responses:
        "200":
          description: The updated Collection.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CollectionCompanyShowResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
    delete:
      tags: [company]
      summary: Destroy a Collection
      operationId: companyCollectionsDestroy
      description: |
        Token-authenticated. HARD-delete — Collection does not yet use
        the Discard concern. Fires a `collections.deleted` webhook
        post-commit.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the collection.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "204":
          description: Deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/collections/{id}/shares:
    get:
      tags: [company]
      summary: Share statistics for a Collection
      operationId: companyCollectionsShares
      description: |
        Read-only aggregate share stats. Same response shape every
        storefront resource returns from `GET :id/shares`.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the collection.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Share statistics.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SharesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/collections/{id}/products:
    get:
      tags: [company]
      summary: Products in a Collection
      operationId: companyCollectionProducts
      description: |
        Every product filed under this collection, including the draft and
        archived rows the public listing hides.

        Same response body, filters, sorting, search, country-relative
        pricing, and cursor pagination as
        `GET /api/v202604/company/products`. The collection in the path always
        wins — a `filter[collection_ids]` in the query string cannot widen the
        listing beyond it.

        Requires `storefront.view` (legacy `collections.view` still accepted).
      parameters:
        - name: id
          description: The unique identifier of the collection whose products to list.
          in: path
          required: true
          schema:
            type: integer
        - $ref: "#/components/parameters/Lang"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/Q"
        - $ref: "#/components/parameters/PageCursor"
        - $ref: "#/components/parameters/PageLimit"
        - name: filter[status]
          in: query
          required: false
          description: Restrict to a single stored lifecycle state.
          schema:
            type: string
            enum: [active, draft, archived]
        - name: filter[availability]
          in: query
          required: false
          description: |
            Stock filter. `in_stock` restricts to purchasable products;
            `all` (default) imposes no stock constraint.
          schema:
            type: string
            enum: [all, in_stock]
            default: all
        - name: filter[bundle]
          in: query
          required: false
          description: |
            `true` returns only bundle products; `false` returns only
            non-bundle products.
          schema:
            type: boolean
        - name: sort
          in: query
          required: false
          description: |
            Single sort key. Prefix with `-` for descending. Default
            `priority` then `id`.
          schema:
            type: string
            enum: [priority, -priority, title, -title, created_at, -created_at]
      responses:
        "200":
          description: A page of products in this collection, every lifecycle state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/UnprocessableEntity"
  /api/v202604/company/collections/{id}/lighthouse:
    get:
      tags: [company]
      summary: Latest Lighthouse result
      operationId: companyCollectionsLighthouseShow
      description: |
        Returns the most recent Lighthouse scan for the Collection,
        including the full Lighthouse JSON `result` blob. When no scan
        exists, queues one and returns 202 with `scan_status`.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the collection.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest Lighthouse result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseResult"
        "202":
          description: |
            No Lighthouse scan existed yet; one was triggered
            automatically. Poll `GET :id/lighthouse` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LighthouseScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a Lighthouse scan
      operationId: companyCollectionsLighthouseScan
      description: |
        Queues a fresh Lighthouse scan. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/lighthouse` when
        the job completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the collection.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/v202604/company/collections/{id}/compliance:
    get:
      tags: [company]
      summary: Latest compliance result
      operationId: companyCollectionsComplianceShow
      description: |
        Returns the most recent compliance scan result for the
        Collection. A fresh scan is queued automatically on every
        company create + update.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the collection.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Latest compliance result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceResult"
        "202":
          description: |
            No compliance scan existed yet; one was triggered
            automatically. Poll `GET :id/compliance` for the result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ComplianceScanTriggered"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    post:
      tags: [company]
      summary: Trigger a compliance scan
      operationId: companyCollectionsComplianceScan
      description: |
        Queues a fresh compliance scan. Returns immediately with `202
        Accepted`; the new result lands at `GET :id/compliance` when
        the job completes.
      security:
        - bearer_auth: []
      parameters:
        - name: id
          description: The unique identifier of the collection.
          in: path
          required: true
          schema:
            type: integer
      responses:
        "202":
          description: Scan accepted and queued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ScanQueued"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
