> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fluid.app/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> For new direct REST integrations, use the v2026-04 surfaces. The @fluid-app FairShare SDK continues to use its own published public-v2025-06 contract.
> Authenticate with the header Authorization: Bearer <token>; public storefront read endpoints require no auth.
> Lists use cursor pagination via the page[cursor] and page[limit] query params; follow meta.pagination.next_cursor until it is null.
> Never use /api/company/v1 or /api/v1 paths, page/per_page params, or offset pagination — they are legacy.
> The OpenAPI specs under api-reference/ are the authoritative contracts; prefer them over prose when in doubt. api-reference/storefront-v2026-04.yaml covers the v2026-04 storefront surface (/api/v202604/... paths); api-reference/auth-v0.yaml covers the unversioned auth surface (/api/... paths — authentication, MFA, social auth, and token exchange); api-reference/checkout-v2026-04.yaml covers the v2026-04 checkout surface (/api/checkout/v2026-04/... paths — carts, cart auth, discounts, items, subscriptions, orders, enrollments, and store config); api-reference/public-v2025-06.yaml covers the Public SDK surface used by the @fluid-app FairShare SDK, including its parallel cart lifecycle, browser integrations, versioned payment callbacks, unversioned public utilities, and the cart price-override operation; api-reference/payment-v2026-04.yaml covers the v2026-04 payment gateway admin surface (/api/payment/v2026-04/... paths, bearer-authenticated — gateway CRUD, gateway purchase/authorize/$0-verify, transaction list/show and capture/void/credit, and merchant payment configuration); api-reference/payments-v2026-04.yaml covers the v2026-04 cart payment surface (/api/payments/v2026-04/carts/{cart_token}/... paths, authenticated by the cart token in the path with no bearer — payment-method selection, VGS card tokenization, 3D Secure verification, and PayPal/Braintree/Klarna/Apple Pay flows); api-reference/commerce-v2026-04.yaml covers the v2026-04 commerce order-editing surface (/api/v202604/orders/{order_id}/edits paths, bearer-authenticated — post-checkout order edits that atomically insert items and add adjustments/discounts, with an optional dry-run preview); api-reference/webhooks-v0.yaml covers the unversioned webhooks surface (/api/... paths — webhook registration, delivery payloads, callback registrations, company events, and webhook/callback schemas).
> Successful responses wrap the resource payload alongside a top-level integer status and a meta object.

# Create a playlist

> 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.




## OpenAPI

````yaml /api-reference/storefront-v2026-04.yaml post /api/v202604/company/playlists
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
security: []
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.
paths:
  /api/v202604/company/playlists:
    post:
      tags:
        - company
      summary: Create a playlist
      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.
      operationId: companyPlaylistsCreate
      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'
      security:
        - bearer_auth: []
components:
  parameters:
    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
    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
  schemas:
    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
    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
    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'
    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
    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'
    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'
    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
    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'
    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'
    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
    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
  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'
    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'
  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: []`).

````