> For the complete documentation index, see [llms.txt](https://docs.firework.com/firework-for-developers/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.firework.com/firework-for-developers/api/products.md).

# Products

### 1. Overview

The Firework Product API allows you to manage products and product units and retrieve product-related media on the Firework platform. It supports listing, retrieving, upserting, partially updating, and deleting products; reading, updating, and deleting individual units; and retrieving videos or library images tagged with a product.

Products are scoped to a **business store**. You must have a business store before creating products. Use the [Business Store API](/firework-for-developers/api/business-stores.md) to manage stores.

The API supports flexible product identification — you can look up products by Firework ID, external product or unit ID, SKU, GTIN, MPN, or barcode.

**Base URL**: `https://api.firework.com`

***

### 2. Authentication

The Firework Product API uses OAuth 2.0 for authentication. Before using this API, you must obtain an access token.

**Authentication Methods Supported:**

* **Client Credentials**: OAuth 2.0 Client Credentials flow for server-to-server authentication (machine-to-machine)
* **User Authentication**: Standard OAuth 2.0 user authorization flow

**Important**: The access token must be from an authenticated user or OAuth app with permission to access the specified business.

**Scopes:**

| Scope            | Description                                                                         |
| ---------------- | ----------------------------------------------------------------------------------- |
| `products:read`  | Read access to products, product units, and product videos                          |
| `products:write` | Write access to products and product units. Implicitly grants `products:read`       |
| `images:read`    | List library images featuring a product; requires the `images` subscription feature |

> 📖 **Documentation:**
>
> * [Client Credentials OAuth](/firework-for-developers/api/authentication.md) - Server-to-server authentication for OAuth apps

***

### 3. Endpoint Summary

| Endpoint                                   | Method | Scope            | Notes                                            |
| ------------------------------------------ | ------ | ---------------- | ------------------------------------------------ |
| `/api/v1/products`                         | GET    | `products:read`  | List or search products in a store               |
| `/api/v1/products/{product_id}`            | GET    | `products:read`  | Get a product by Firework or external identifier |
| `/api/v1/products`                         | POST   | `products:write` | Upsert a product; merge supplied units           |
| `/api/v1/products/{product_id}`            | PATCH  | `products:write` | Partially update a product                       |
| `/api/v1/products/{product_id}`            | DELETE | `products:write` | Delete a product                                 |
| `/api/v1/products/{product_id}/units/{id}` | GET    | `products:read`  | Get one product unit                             |
| `/api/v1/products/{product_id}/units/{id}` | PATCH  | `products:write` | Partially update one product unit                |
| `/api/v1/products/{product_id}/units/{id}` | DELETE | `products:write` | Delete one product unit                          |
| `/api/v1/products/{product_id}/videos`     | GET    | `products:read`  | Retrieve videos tagged with a product            |
| `/api/v1/products/{product_id}/images`     | GET    | `images:read`    | Retrieve published library images for a product  |

***

### 4. Product Object Reference

The product object is returned by the List, Get, Upsert, and Update endpoints. All Firework-managed IDs in responses are encoded strings.

#### 4.1. Product Fields

| Field               | Type      | Nullable | Description                                                                                           |
| ------------------- | --------- | -------- | ----------------------------------------------------------------------------------------------------- |
| `id`                | string    | ❌        | Encoded unique product identifier                                                                     |
| `external_id`       | string    | ❌        | External product identifier from your system, or a Firework-generated identifier if omitted on create |
| `name`              | string    | ❌        | Display name of the product                                                                           |
| `description`       | string    | ✅        | Product description (plain text)                                                                      |
| `currency`          | string    | ❌        | Three-letter ISO 4217 currency code (e.g., `"USD"`, `"EUR"`)                                          |
| `handle`            | string    | ✅        | URL handle or slug for the product page                                                               |
| `options`           | string\[] | ❌        | Available option names (e.g., `["color", "size"]`). Empty if none                                     |
| `category`          | string    | ✅        | Product category (free-form text)                                                                     |
| `hide_price`        | boolean   | ❌        | Whether to hide price display (default: `false`)                                                      |
| `brand`             | string    | ✅        | Product brand name                                                                                    |
| `subtitle`          | string    | ✅        | Product subtitle (max 75 characters)                                                                  |
| `shipping`          | string    | ✅        | Shipping information                                                                                  |
| `custom_cta`        | object    | ✅        | Custom call-to-action button configuration (see [Custom CTA Object](#45-custom-cta-object))           |
| `business_store_id` | string    | ❌        | Encoded ID of the business store this product belongs to                                              |
| `images`            | object\[] | ❌        | Array of product images (see [Image Object](#42-image-object))                                        |
| `units`             | object\[] | ❌        | Array of product variants/units (see [Unit Object](#43-unit-object))                                  |

#### 4.2. Image Object

| Field               | Type      | Nullable | Description                                                          |
| ------------------- | --------- | -------- | -------------------------------------------------------------------- |
| `id`                | string    | ❌        | Encoded unique image identifier                                      |
| `external_id`       | string    | ✅        | External identifier for the image                                    |
| `url`               | string    | ❌        | URL of the image                                                     |
| `position`          | integer   | ❌        | Display position (0-indexed), derived from image order during upsert |
| `unit_external_ids` | string\[] | ❌        | External IDs of units this image applies to. Empty if global         |

#### 4.3. Unit Object

| Field            | Type      | Nullable | Description                                                         |
| ---------------- | --------- | -------- | ------------------------------------------------------------------- |
| `id`             | string    | ❌        | Encoded unique unit identifier                                      |
| `external_id`    | string    | ✅        | External identifier for the unit/variant                            |
| `name`           | string    | ❌        | Display name of the unit/variant (e.g., `"Red / Large"`)            |
| `price`          | number    | ❌        | Current price as a number (e.g., `1338.00`)                         |
| `original_price` | number    | ✅        | Original price before discount                                      |
| `url`            | string    | ❌        | Direct URL to purchase this variant                                 |
| `position`       | integer   | ❌        | Display position (0-indexed). Defaults to `0` if omitted            |
| `quantity`       | integer   | ✅        | Available stock quantity                                            |
| `options`        | object\[] | ❌        | Option values for this variant (see [Unit Option](#44-unit-option)) |
| `sku`            | string    | ✅        | Stock Keeping Unit                                                  |
| `gtin`           | string    | ✅        | Global Trade Item Number (UPC, EAN, ISBN, etc.)                     |
| `mpn`            | string    | ✅        | Manufacturer Part Number                                            |
| `barcode`        | string    | ✅        | Barcode value                                                       |

#### 4.4. Unit Option

| Field   | Type   | Nullable | Description                   |
| ------- | ------ | -------- | ----------------------------- |
| `name`  | string | ❌        | Option name (e.g., `"color"`) |
| `value` | string | ❌        | Option value (e.g., `"Red"`)  |

#### 4.5. Custom CTA Object

The custom call-to-action (CTA) object configures an optional action button that appears on the video overlay for this product. A product can have at most one custom CTA.

| Field               | Type    | Nullable | Description                                                                                                          |
| ------------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `title`             | string  | ❌        | One of the supported CTA title keys (see [Supported CTA Titles](#supported-cta-titles)). Must be present with `url`. |
| `url`               | string  | ❌        | Destination URL when the button is clicked. Max 4000 characters. Must be present with `title`.                       |
| `target`            | string  | ❌        | Link target behavior. One of `"_blank"` (default), `"_self"`, `"iframe"`.                                            |
| `hide_primary`      | boolean | ❌        | When `true`, hides Firework's default purchase button so only the custom CTA is visible. Default: `false`.           |
| `title_translation` | string  | ❌        | *Response only.* Localized display label for the title, derived from the merchant's locale. Not accepted on input.   |

**Notes:**

* When a product has no custom CTA configured, the `custom_cta` field in API responses is `null`. When non-null, all keys are guaranteed present.
* `title_translation` falls back to the default English label (e.g., `"Buy Now"` for `buy_now`) when no localized translation exists for the merchant's locale.

**Supported CTA Titles**

| Title key                | Default English label  |
| ------------------------ | ---------------------- |
| `order_now`              | Order Now              |
| `buy_now`                | Buy Now                |
| `sign_up`                | Sign Up                |
| `enter_now`              | Enter Now              |
| `enroll_now`             | Enroll Now >           |
| `claim_sample`           | Claim Sample           |
| `take_the_quiz`          | Take The Quiz          |
| `see_recipe`             | See Recipe             |
| `see_more`               | See More               |
| `see_details`            | See Details            |
| `start_the_journey`      | Start The Journey      |
| `personalize_my_blend`   | Personalize My Blend   |
| `order_your_welcome_kit` | Order Your Welcome Kit |

Display labels are localized per merchant locale via Firework's translation system.

**Validation Rules**

* `title` and `url` must both be present or both be absent. Sending only one returns `422 Unprocessable Entity`.
* `title` must be one of the supported title keys listed above.
* `target` must be one of `"_blank"`, `"_self"`, `"iframe"`.
* `url` must not exceed 4000 characters.

***

### 5. List Products

List or search the products in a business store. By default (no `search` or `brand`), all of the store's products are returned newest-first. Supplying `search` and/or `brand` runs a full-text search over the store's catalog instead.

**Endpoint**: `GET /api/v1/products` **Authentication**: Bearer token required (OAuth 2.0 Client Credentials) **Required Scope**: `products:read` (for OAuth apps)

#### 5.1. Request Headers

| Name            | Description                           | Required |
| --------------- | ------------------------------------- | -------- |
| `Authorization` | Bearer token: `Bearer {ACCESS_TOKEN}` | ✅        |

#### 5.2. Query Parameters

| Parameter           | Type    | Required | Default | Description                                                                                                                                           |
| ------------------- | ------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `business_store_id` | string  | ✅        | None    | Encoded business store ID to list/search products in                                                                                                  |
| `search`            | string  | ❌        | None    | Full-text search term matched against the store's catalog. Omit to list every product in the store                                                    |
| `brand`             | string  | ❌        | None    | Filter to products whose brand matches this value (case-insensitive exact match). Leading/trailing whitespace is trimmed; omit to not filter by brand |
| `after`             | string  | ❌        | None    | Opaque cursor for the next page (from `pagination.cursor` or `links.next`). Ascending order                                                           |
| `before`            | string  | ❌        | None    | Opaque cursor for the previous page. Descending order                                                                                                 |
| `page_size`         | integer | ❌        | 10      | Number of products per page (max: 100). Values above the max are clamped                                                                              |

> **Note**: `search` and `brand` are independent — you may supply either, both, or neither. Providing either one switches the endpoint from a plain store listing to a full-text search of the store's catalog.

#### 5.3. List Products Response

**Success Response**: `200 OK`

Each entry in `products` is a full product object as described in the [Product Object Reference](#4-product-object-reference).

| Field        | Type      | Nullable | Description                                                                            |
| ------------ | --------- | -------- | -------------------------------------------------------------------------------------- |
| `products`   | object\[] | ❌        | Array of product objects (see [Product Object Reference](#4-product-object-reference)) |
| `links`      | object    | ❌        | Pagination links (see [Pagination Envelope](#pagination-envelope) below)               |
| `pagination` | object    | ❌        | Pagination state (see [Pagination Envelope](#pagination-envelope) below)               |

**Pagination Envelope**

The response uses cursor-based pagination. Treat the values in `links` and `pagination` as opaque and follow them verbatim.

| Field                 | Type    | Nullable | Description                                                                            |
| --------------------- | ------- | -------- | -------------------------------------------------------------------------------------- |
| `links.next`          | string  | ✅        | Relative path to the next page. `null` when there are no more results                  |
| `pagination.cursor`   | string  | ✅        | Opaque cursor for the next page (pass back as `after`/`before`). `null` when exhausted |
| `pagination.has_more` | boolean | ❌        | `true` when another page is available now                                              |

> **Note**: This is a forward-only cursor feed, so `links` omits `prev`. A deprecated `paging` object (`{"next": ...}`) is also returned during the pagination migration window; prefer `links` + `pagination` and do not build on `paging`.

```json
{
  "products": [
    {
      "id": "vWKDjg",
      "external_id": "SHOE-001",
      "name": "Classic Running Shoe",
      "description": "Lightweight running shoe for everyday training",
      "currency": "USD",
      "handle": "https://example.com/products/classic-running-shoe",
      "options": ["color", "size"],
      "category": "Footwear",
      "hide_price": false,
      "brand": "RunCo",
      "subtitle": "Everyday training shoe",
      "shipping": "Free shipping over $50",
      "custom_cta": null,
      "business_store_id": "y8PDj8",
      "images": [
        {
          "id": "mK9x2p",
          "external_id": "IMG-001",
          "url": "https://example.com/images/shoe-gray.jpg",
          "position": 0,
          "unit_external_ids": ["SHOE-001-GR-10"]
        }
      ],
      "units": [
        {
          "id": "nP3qRv",
          "external_id": "SHOE-001-GR-10",
          "name": "Gray / Size 10",
          "price": 129.99,
          "original_price": 149.99,
          "url": "https://example.com/products/classic-running-shoe?variant=gray-10",
          "position": 0,
          "quantity": 25,
          "options": [
            { "name": "color", "value": "Gray" },
            { "name": "size", "value": "10" }
          ],
          "sku": "SKU-SHOE-GR10",
          "gtin": "071249656457",
          "mpn": null,
          "barcode": null
        }
      ]
    }
  ],
  "links": {
    "next": "/api/v1/products?business_store_id=y8PDj8&page_size=10&before=Q3Vyc29yOjEyMw"
  },
  "pagination": {
    "cursor": "Q3Vyc29yOjEyMw",
    "has_more": true
  }
}
```

On the last page, `links.next` and `pagination.cursor` are `null` and `pagination.has_more` is `false`.

#### 5.4. List Products Error Responses

| Status Code        | Description                                                   |
| ------------------ | ------------------------------------------------------------- |
| `400 Bad Request`  | Missing `business_store_id`, or invalid pagination parameters |
| `401 Unauthorized` | Invalid or missing authentication token                       |
| `403 Forbidden`    | Insufficient scope or store belongs to another business       |
| `404 Not Found`    | Business store not found                                      |

#### 5.5. Examples

**List all products in a store**

```bash
curl -X GET "https://api.firework.com/api/v1/products?business_store_id=y8PDj8&page_size=10" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Full-text search**

```bash
curl -X GET "https://api.firework.com/api/v1/products?business_store_id=y8PDj8&search=running%20shoe" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Filter by brand**

```bash
curl -X GET "https://api.firework.com/api/v1/products?business_store_id=y8PDj8&brand=RunCo" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Next page (using cursor from previous response)**

```bash
curl -X GET "https://api.firework.com/api/v1/products?business_store_id=y8PDj8&page_size=10&before=Q3Vyc29yOjEyMw" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

***

### 6. Get Product

Retrieve details of a single product by Firework ID, external product/unit ID, SKU, GTIN, MPN, or barcode.

**Endpoint**: `GET /api/v1/products/{product_id}` **Authentication**: Bearer token required (OAuth 2.0 Client Credentials) **Required Scope**: `products:read` (for OAuth apps)

#### 6.1. Request Headers

| Name            | Description                           | Required |
| --------------- | ------------------------------------- | -------- |
| `Authorization` | Bearer token: `Bearer {ACCESS_TOKEN}` | ✅        |

#### 6.2. Path Parameters

| Parameter    | Type   | Required | Description                                                             |
| ------------ | ------ | -------- | ----------------------------------------------------------------------- |
| `product_id` | string | ✅        | Product identifier (see [Product Identifiers](#14-product-identifiers)) |

#### 6.3. Query Parameters

| Parameter           | Type   | Required | Description                                                                                                                                          |
| ------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `business_store_id` | string | ❌        | Store used for non-ID identifiers. Required when the business has multiple stores; optional for an encoded product ID, but must match when supplied. |

#### 6.4. Get Product Response

**Success Response**: `200 OK`

```json
{
  "id": "vWKDjg",
  "external_id": "SHOE-001",
  "name": "Classic Running Shoe",
  "description": "Lightweight running shoe for everyday training",
  "currency": "USD",
  "handle": "https://example.com/products/classic-running-shoe",
  "options": ["color", "size"],
  "category": "Footwear",
  "hide_price": false,
  "custom_cta": {
    "title": "buy_now",
    "url": "https://shop.example.com/classic-running-shoe",
    "target": "_blank",
    "hide_primary": false,
    "title_translation": "Buy Now"
  },
  "business_store_id": "y8PDj8",
  "images": [
    {
      "id": "mK9x2p",
      "external_id": "IMG-001",
      "url": "https://example.com/images/shoe-gray.jpg",
      "position": 0,
      "unit_external_ids": ["SHOE-001-GR-10"]
    }
  ],
  "units": [
    {
      "id": "nP3qRv",
      "external_id": "SHOE-001-GR-10",
      "name": "Gray / Size 10",
      "price": 129.99,
      "original_price": 149.99,
      "url": "https://example.com/products/classic-running-shoe?variant=gray-10",
      "position": 0,
      "quantity": 25,
      "options": [
        { "name": "color", "value": "Gray" },
        { "name": "size", "value": "10" }
      ],
      "sku": "SKU-SHOE-GR10",
      "gtin": "071249656457",
      "mpn": null,
      "barcode": null
    }
  ]
}
```

> **Note**: The response fields are the same as those described in the [Product Object Reference](#4-product-object-reference).

#### 6.5. Get Product Error Responses

| Status Code        | Description                                                                                            |
| ------------------ | ------------------------------------------------------------------------------------------------------ |
| `400 Bad Request`  | A non-ID identifier is ambiguous because `business_store_id` is required, or the store ID is malformed |
| `401 Unauthorized` | Invalid or missing authentication token                                                                |
| `403 Forbidden`    | Insufficient scope or product belongs to another business                                              |
| `404 Not Found`    | Product not found for the supplied identifier                                                          |

#### 6.6. Examples

**CURL Request — By Firework ID**

```bash
curl -X GET "https://api.firework.com/api/v1/products/vWKDjg" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**CURL Request — By SKU**

```bash
curl -X GET "https://api.firework.com/api/v1/products/SKU-SHOE-GR10?business_store_id=y8PDj8" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

***

### 7. Upsert Product

Create or update a product in a business store. If a product with the same `external_id` already exists in the specified store, it is updated; otherwise, a new product is created.

**Endpoint**: `POST /api/v1/products` **Authentication**: Bearer token required (OAuth 2.0 Client Credentials) **Required Scope**: `products:write` (for OAuth apps) **Content Type**: `application/json`

#### 7.1. Request Headers

| Name            | Description                           | Required |
| --------------- | ------------------------------------- | -------- |
| `Authorization` | Bearer token: `Bearer {ACCESS_TOKEN}` | ✅        |
| `Content-Type`  | Must be `application/json`            | ✅        |

#### 7.2. Request Body

**Product Fields**

| Field               | Type      | Required | Default        | Description                                                                                                                                              |
| ------------------- | --------- | -------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `business_store_id` | string    | ✅        | None           | Encoded business store ID to create/update the product in                                                                                                |
| `external_id`       | string    | ❌        | Auto-generated | External product identifier from your system (used for matching). If omitted, a unique ID is auto-generated and the request always creates a new product |
| `name`              | string    | ✅        | None           | Display name of the product                                                                                                                              |
| `description`       | string    | ❌        | None           | Product description (plain text)                                                                                                                         |
| `handle`            | string    | ❌        | None           | URL handle or slug for the product page                                                                                                                  |
| `options`           | string\[] | ❌        | `[]`           | Available option names (e.g., `["color", "size"]`)                                                                                                       |
| `category`          | string    | ❌        | None           | Product category (free-form text)                                                                                                                        |
| `hide_price`        | boolean   | ❌        | `false`        | Whether to hide price display                                                                                                                            |
| `brand`             | string    | ❌        | None           | Product brand name                                                                                                                                       |
| `subtitle`          | string    | ❌        | None           | Product subtitle (max 75 characters)                                                                                                                     |
| `shipping`          | string    | ❌        | None           | Shipping information                                                                                                                                     |
| `custom_cta`        | object    | ❌        | `null`         | Custom call-to-action configuration (see [Custom CTA Input](#custom-cta-input)). Send `null` to clear an existing CTA.                                   |
| `product_images`    | object\[] | ❌        | `[]`           | Array of product images (see [Image Input](#image-input))                                                                                                |
| `product_units`     | object\[] | ❌        | `[]`           | Array of product variants/units (see [Unit Input](#unit-input))                                                                                          |
| `sku`               | string    | ❌        | None           | Simple-product convenience field applied to the product's single unit                                                                                    |
| `gtin`              | string    | ❌        | None           | Simple-product convenience field applied to the product's single unit                                                                                    |
| `mpn`               | string    | ❌        | None           | Simple-product convenience field applied to the product's single unit                                                                                    |
| `barcode`           | string    | ❌        | None           | Simple-product convenience field applied to the product's single unit                                                                                    |
| `price`             | number    | ❌        | None           | Simple-product convenience field applied to the product's single unit                                                                                    |
| `original_price`    | number    | ❌        | None           | Simple-product convenience field applied to the product's single unit; `null` clears it                                                                  |
| `quantity`          | integer   | ❌        | None           | Simple-product convenience field applied to the product's single unit                                                                                    |
| `url`               | string    | ❌        | None           | Simple-product convenience field applied to the product's single unit                                                                                    |

**Image Input**

| Field              | Type      | Required | Default | Description                                 |
| ------------------ | --------- | -------- | ------- | ------------------------------------------- |
| `external_id`      | string    | ❌        | None    | External identifier for the image           |
| `url`              | string    | ✅        | None    | URL of the image                            |
| `unit_identifiers` | string\[] | ❌        | `[]`    | External IDs of units this image applies to |

Image position is derived from the image's order in the `product_images` array. The request body does not accept a `position` field for images.

**Unit Input**

| Field            | Type      | Required    | Default | Description                                                                                                          |
| ---------------- | --------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
| `external_id`    | string    | Conditional | None    | Stable unit identifier. Optional on first create; required for every supplied unit when updating an existing product |
| `name`           | string    | ✅           | None    | Display name of the unit/variant                                                                                     |
| `price`          | number    | ✅           | None    | Current price (e.g., `129.99`)                                                                                       |
| `original_price` | number    | ❌           | None    | Original price before discount (e.g., `149.99`)                                                                      |
| `url`            | string    | ✅           | None    | Direct URL to purchase this variant                                                                                  |
| `quantity`       | integer   | ❌           | None    | Available stock quantity                                                                                             |
| `options`        | object\[] | ✅           | None    | Option values for this variant (pass `[]` if no options)                                                             |
| `sku`            | string    | ❌           | None    | Stock Keeping Unit                                                                                                   |
| `gtin`           | string    | ❌           | None    | Global Trade Item Number (UPC, EAN, ISBN, etc.)                                                                      |
| `mpn`            | string    | ❌           | None    | Manufacturer Part Number                                                                                             |
| `barcode`        | string    | ❌           | None    | Barcode value                                                                                                        |

Unit position is derived from the unit's order in the `product_units` array. The request body does not accept a `position` field for units.

Each unit option object has:

| Field   | Type   | Required | Description                   |
| ------- | ------ | -------- | ----------------------------- |
| `name`  | string | ✅        | Option name (e.g., `"color"`) |
| `value` | string | ✅        | Option value (e.g., `"Gray"`) |

**Custom CTA Input**

Optional. If provided, must be an object matching the [Custom CTA Object](#45-custom-cta-object) shape.

| Field          | Type    | Required | Default    | Description                                                                   |
| -------------- | ------- | -------- | ---------- | ----------------------------------------------------------------------------- |
| `title`        | string  | ❌        | None       | One of the supported CTA title keys. Must be present together with `url`.     |
| `url`          | string  | ❌        | None       | Destination URL (max 4000 characters). Must be present together with `title`. |
| `target`       | string  | ❌        | `"_blank"` | Link behavior: `"_blank"`, `"_self"`, or `"iframe"`.                          |
| `hide_primary` | boolean | ❌        | `false`    | Hide Firework's default purchase button so only the custom CTA is visible.    |

**Update and clearing semantics:**

* To **clear** an existing CTA, send `"custom_cta": null`. The embedded CTA is removed from the product.
* To **preserve** an existing CTA unchanged, omit `custom_cta` from the request body entirely.
* **Partial updates are not supported.** Because `title` and `url` must move together, you cannot update only one of them. Always send the full custom CTA object when modifying it.

**Important Update Behavior**

* `product_units` are merged by unit `external_id`. Matching units update in place and keep their Firework IDs and video/listing associations; new identifiers add units; omitted units remain untouched.
* Every supplied `product_units` entry must include `external_id` when the product already exists. Omitting it returns `422` instead of creating a duplicate unit.
* Product upsert and update never remove units. Use `DELETE /api/v1/products/{product_id}/units/{id}` to remove one.
* The product-level unit fields (`sku`, `gtin`, `mpn`, `barcode`, `price`, `original_price`, `quantity`, `url`) are for simple products. They cannot be combined with `product_units`, and a product with multiple units rejects them with `422`.
* If `product_images` is included, the product image collection follows the existing product-image replacement behavior. Image-to-unit associations are rebuilt from `product_images[].unit_identifiers`.

#### 7.3. Upsert Product Response

**Success Response**: `201 Created`

The response returns the full product object as described in the [Product Object Reference](#4-product-object-reference).

```json
{
  "id": "vWKDjg",
  "external_id": "SHOE-001",
  "name": "Classic Running Shoe",
  "description": "Lightweight running shoe for everyday training",
  "currency": "USD",
  "handle": "https://example.com/products/classic-running-shoe",
  "options": ["color", "size"],
  "category": "Footwear",
  "hide_price": false,
  "brand": "RunCo",
  "subtitle": "Everyday training shoe",
  "shipping": "Free shipping over $50",
  "custom_cta": {
    "title": "buy_now",
    "url": "https://shop.example.com/classic-running-shoe",
    "target": "_blank",
    "hide_primary": false,
    "title_translation": "Buy Now"
  },
  "business_store_id": "y8PDj8",
  "images": [
    {
      "id": "mK9x2p",
      "external_id": "IMG-001",
      "url": "https://example.com/images/shoe-gray.jpg",
      "position": 0,
      "unit_external_ids": ["SHOE-001-GR-10"]
    }
  ],
  "units": [
    {
      "id": "nP3qRv",
      "external_id": "SHOE-001-GR-10",
      "name": "Gray / Size 10",
      "price": 129.99,
      "original_price": 149.99,
      "url": "https://example.com/products/classic-running-shoe?variant=gray-10",
      "position": 0,
      "quantity": 25,
      "options": [
        { "name": "color", "value": "Gray" },
        { "name": "size", "value": "10" }
      ],
      "sku": "SKU-SHOE-GR10",
      "gtin": "071249656457",
      "mpn": null,
      "barcode": null
    }
  ]
}
```

#### 7.4. Upsert Product Error Responses

| Status Code                | Description                                                                                                                                                                     |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`          | Missing required fields or malformed JSON                                                                                                                                       |
| `401 Unauthorized`         | Invalid or missing authentication token                                                                                                                                         |
| `403 Forbidden`            | Insufficient scope or no access to the specified business store                                                                                                                 |
| `404 Not Found`            | Business store not found                                                                                                                                                        |
| `422 Unprocessable Entity` | Validation errors, a sent unit missing `external_id` on an existing product, conflicting simple-product/unit fields, multi-variant simple-field use, or invalid nested data/CTA |

#### 7.5. Examples

**CURL Request**

```bash
curl -X POST "https://api.firework.com/api/v1/products" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "business_store_id": "y8PDj8",
    "external_id": "SHOE-001",
    "name": "Classic Running Shoe",
    "description": "Lightweight running shoe for everyday training",
    "handle": "https://example.com/products/classic-running-shoe",
    "options": ["color", "size"],
    "category": "Footwear",
    "product_images": [
      {
        "external_id": "IMG-001",
        "url": "https://example.com/images/shoe-gray.jpg",
        "unit_identifiers": ["SHOE-001-GR-10"]
      }
    ],
    "product_units": [
      {
        "external_id": "SHOE-001-GR-10",
        "name": "Gray / Size 10",
        "price": "129.99",
        "original_price": "149.99",
        "url": "https://example.com/products/classic-running-shoe?variant=gray-10",
        "quantity": 25,
        "options": [
          { "name": "color", "value": "Gray" },
          { "name": "size", "value": "10" }
        ],
        "sku": "SKU-SHOE-GR10",
        "gtin": "071249656457"
      }
    ]
  }'
```

**Minimal Upsert (product with no variants)**

```bash
curl -X POST "https://api.firework.com/api/v1/products" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "business_store_id": "y8PDj8",
    "external_id": "GIFT-CARD-50",
    "name": "$50 Gift Card",
    "price": 50.00,
    "url": "https://example.com/products/gift-card-50",
    "sku": "GIFT-CARD-50"
  }'
```

**Upsert with Custom CTA (alongside default purchase button)**

```bash
curl -X POST "https://api.firework.com/api/v1/products" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "business_store_id": "y8PDj8",
    "external_id": "SHOE-001",
    "name": "Classic Running Shoe",
    "custom_cta": {
      "title": "see_details",
      "url": "https://shop.example.com/classic-running-shoe"
    },
    "brand": "RunCo"
  }'
```

Both Firework's default purchase button and the custom "See Details" button are visible. `target` defaults to `"_blank"` and `hide_primary` defaults to `false` when omitted.

**Upsert with Custom CTA (replacing default purchase button)**

```bash
curl -X POST "https://api.firework.com/api/v1/products" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "business_store_id": "y8PDj8",
    "external_id": "SHOE-001",
    "name": "Classic Running Shoe",
    "custom_cta": {
      "title": "buy_now",
      "url": "https://shop.example.com/classic-running-shoe",
      "hide_primary": true
    },
    "brand": "RunCo"
  }'
```

Only the custom "Buy Now" button is visible. Firework's default purchase button is hidden.

**Clearing an existing Custom CTA**

```bash
curl -X POST "https://api.firework.com/api/v1/products" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "business_store_id": "y8PDj8",
    "external_id": "SHOE-001",
    "name": "Classic Running Shoe",
    "custom_cta": null
  }'
```

Sending `"custom_cta": null` removes the custom CTA entirely from the product. Omitting the `custom_cta` field (rather than setting it to `null`) leaves any existing CTA unchanged.

***

### 8. Update Product

Partially update a product without replacing omitted fields. This endpoint can also change the product's `external_id`; the upsert endpoint cannot re-key a product because it uses `external_id` to select the record.

**Endpoint**: `PATCH /api/v1/products/{product_id}` **Authentication**: Bearer token required **Required Scope**: `products:write` **Content Type**: `application/json`

#### 8.1. Request Headers

| Name            | Description                           | Required |
| --------------- | ------------------------------------- | -------- |
| `Authorization` | Bearer token: `Bearer {ACCESS_TOKEN}` | ✅        |
| `Content-Type`  | Must be `application/json`            | ✅        |

#### 8.2. Parameters

| Parameter           | Location | Type   | Required | Description                                                                                                                                           |
| ------------------- | -------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `product_id`        | Path     | string | ✅        | Product identifier (see [Product Identifiers](#14-product-identifiers))                                                                               |
| `business_store_id` | Query    | string | ❌        | Store used for non-ID lookup. Required with multiple stores; optional for an encoded product ID, but must identify the product's store when supplied. |

#### 8.3. Request Body

The body accepts the product fields documented in [Upsert Product](#72-request-body), except `business_store_id`. Only supplied fields change.

Additional rules:

* `external_id` changes the product's identifier and must be unique in the store. An empty string generates a new identifier.
* `product_units` use the same merge semantics as upsert: matching `external_id` values update in place, omitted units remain, and every supplied unit must include `external_id`.
* Product-level unit convenience fields update the single unit of a simple product. They cannot be combined with `product_units` and cannot target a multi-variant product.
* `custom_cta: null` clears the CTA; omitting it preserves the CTA.

#### 8.4. Response and Errors

`200 OK` returns the complete updated [Product Object](#4-product-object-reference).

| Status Code                | Description                                                                                     |
| -------------------------- | ----------------------------------------------------------------------------------------------- |
| `400 Bad Request`          | Malformed identifier/store input, or a required store is ambiguous                              |
| `401 Unauthorized`         | Missing or invalid token                                                                        |
| `403 Forbidden`            | Missing scope or product/store belongs to another business                                      |
| `404 Not Found`            | Product or store not found                                                                      |
| `422 Unprocessable Entity` | Invalid field, duplicate `external_id`, invalid unit merge, or invalid simple-product field use |

#### 8.5. Example

```bash
curl -X PATCH "https://api.firework.com/api/v1/products/SHOE-001?business_store_id=y8PDj8" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "SHOE-001-2026",
    "subtitle": "Updated everyday trainer",
    "product_units": [{
      "external_id": "SHOE-001-GR-10",
      "name": "Gray / Size 10",
      "price": 119.99,
      "url": "https://example.com/products/classic-running-shoe?variant=gray-10",
      "options": [{"name":"color","value":"Gray"},{"name":"size","value":"10"}]
    }]
  }'
```

***

### 9. Product Unit Endpoints

These endpoints address one unit within one product. The product can use any supported product identifier. The unit `id` path value accepts an encoded unit ID or the unit's external ID, SKU, GTIN, or MPN within the resolved store.

#### 9.1. Shared Parameters

| Parameter           | Location | Type   | Required | Description                                                                                                          |
| ------------------- | -------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `product_id`        | Path     | string | ✅        | Parent product identifier                                                                                            |
| `id`                | Path     | string | ✅        | Unit ID, external ID, SKU, GTIN, or MPN                                                                              |
| `business_store_id` | Query    | string | ❌        | Store used for non-ID lookup. Required with multiple stores; optional for encoded IDs, but must match when supplied. |

The resolved unit must belong to the resolved product; otherwise the API returns `404 Not Found`.

#### 9.2. Get Product Unit

**Endpoint**: `GET /api/v1/products/{product_id}/units/{id}` **Required Scope**: `products:read`

`200 OK` returns the [Unit Object](#43-unit-object). Errors are `400`, `401`, `403`, and `404`.

```bash
curl "https://api.firework.com/api/v1/products/SHOE-001/units/SKU-SHOE-GR10?business_store_id=y8PDj8" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

#### 9.3. Update Product Unit

**Endpoint**: `PATCH /api/v1/products/{product_id}/units/{id}` **Required Scope**: `products:write` **Content Type**: `application/json`

Only supplied fields change, and the unit keeps its Firework ID and existing media/listing associations.

| Field            | Type    | Nullable | Description                                                                             |
| ---------------- | ------- | -------- | --------------------------------------------------------------------------------------- |
| `external_id`    | string  | ❌        | New stable external ID; must be unique within the product and cannot be `null` or empty |
| `name`           | string  | ❌        | Unit display name                                                                       |
| `price`          | number  | ❌        | Current price                                                                           |
| `original_price` | number  | ✅        | Original price; `null` clears it                                                        |
| `url`            | string  | ❌        | Purchase URL                                                                            |
| `quantity`       | integer | ❌        | Stock quantity                                                                          |
| `sku`            | string  | ✅        | SKU; `null` clears it                                                                   |
| `gtin`           | string  | ✅        | GTIN; `null` clears it                                                                  |
| `mpn`            | string  | ✅        | MPN; `null` clears it                                                                   |
| `barcode`        | string  | ✅        | Barcode; `null` clears it                                                               |

When `price` or `original_price` changes, the final `original_price` must be at least the final `price`. If raising `price` above the current original price, send a new `original_price` in the same request or clear it with `null`.

`200 OK` returns the updated Unit object. Errors are `400`, `401`, `403`, `404`, and `422`; `422` includes duplicate external IDs/barcodes and validation failures.

```bash
curl -X PATCH "https://api.firework.com/api/v1/products/SHOE-001/units/SKU-SHOE-GR10?business_store_id=y8PDj8" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"price":139.99,"original_price":159.99,"quantity":18,"barcode":null}'
```

#### 9.4. Delete Product Unit

**Endpoint**: `DELETE /api/v1/products/{product_id}/units/{id}` **Required Scope**: `products:write`

This is the only public operation that removes a unit; product upserts and updates never delete omitted units. Success returns `204 No Content`. Errors are `400`, `401`, `403`, and `404`.

```bash
curl -X DELETE "https://api.firework.com/api/v1/products/SHOE-001/units/SKU-SHOE-GR10?business_store_id=y8PDj8" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

***

### 10. Delete Product

Delete a product. This is a soft delete — the product is marked as deleted and will no longer appear in listings or search results.

**Endpoint**: `DELETE /api/v1/products/{product_id}` **Authentication**: Bearer token required (OAuth 2.0 Client Credentials) **Required Scope**: `products:write` (for OAuth apps)

> **Important**: A product cannot be deleted if it is linked to a domain assistant (AVA knowledge base). Remove the product association first.

#### 10.1. Request Headers

| Name            | Description                           | Required |
| --------------- | ------------------------------------- | -------- |
| `Authorization` | Bearer token: `Bearer {ACCESS_TOKEN}` | ✅        |

#### 10.2. Path Parameters

| Parameter    | Type   | Required | Description                                                             |
| ------------ | ------ | -------- | ----------------------------------------------------------------------- |
| `product_id` | string | ✅        | Product identifier (see [Product Identifiers](#14-product-identifiers)) |

#### 10.3. Query Parameters

| Parameter           | Type   | Required | Description                                                                                                                                    |
| ------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `business_store_id` | string | ❌        | Store used for non-ID lookup. Required when the business has multiple stores; optional for an encoded product ID, but must match when supplied |

#### 10.4. Delete Product Response

**Success Response**: `204 No Content`

No response body. The product has been successfully deleted.

#### 10.5. Delete Product Error Responses

| Status Code                | Description                                                                                                |
| -------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`          | Malformed `business_store_id`, or `business_store_id` is required because the business has multiple stores |
| `401 Unauthorized`         | Invalid or missing authentication token                                                                    |
| `403 Forbidden`            | Insufficient scope or product belongs to another business                                                  |
| `404 Not Found`            | Product not found                                                                                          |
| `422 Unprocessable Entity` | Product cannot be deleted (e.g., linked to a domain assistant)                                             |

#### 10.6. Examples

**CURL Request**

```bash
curl -X DELETE "https://api.firework.com/api/v1/products/vWKDjg" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**CURL Request — By External ID (with store scoping)**

```bash
curl -X DELETE "https://api.firework.com/api/v1/products/SHOE-001?business_store_id=y8PDj8" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Success Response**

`204 No Content` (empty body)

**Error Response — Product Linked to Domain Assistant**

```json
{
  "error": "Product cannot be deleted: linked to a domain assistant"
}
```

***

### 11. List Product Videos

Retrieve videos tagged with a specific product. Designed for PDP (Product Detail Page) integration.

**Endpoint**: `GET /api/v1/products/{product_id}/videos` **Authentication**: Bearer token required **Required Scope**: `products:read` (for OAuth apps)

#### 11.1. Request Headers

| Name            | Description                           | Required |
| --------------- | ------------------------------------- | -------- |
| `Authorization` | Bearer token: `Bearer {ACCESS_TOKEN}` | ✅        |

#### 11.2. Path Parameters

| Parameter    | Type   | Required | Description                                                             |
| ------------ | ------ | -------- | ----------------------------------------------------------------------- |
| `product_id` | string | ✅        | Product identifier (see [Product Identifiers](#14-product-identifiers)) |

#### 11.3. Query Parameters

Results are returned in descending order by video ID (newest first) unless an `after` cursor is supplied.

| Parameter           | Type    | Required | Default          | Description                                                                                   |
| ------------------- | ------- | -------- | ---------------- | --------------------------------------------------------------------------------------------- |
| `channel_id`        | string  | ✅        | None             | Firework encoded channel ID                                                                   |
| `business_store_id` | string  | ❌        | None             | Encoded business store ID for product lookup                                                  |
| `accesses`          | string  | ❌        | `public,private` | Comma-separated access filter                                                                 |
| `archived`          | boolean | ❌        | `false`          | Omitted or `false` returns only non-archived videos; `true` returns only archived videos      |
| `page_size`         | integer | ❌        | 10               | Number of videos per page (range: 1–100). Values above the max are clamped                    |
| `after`             | string  | ❌        | None             | Opaque pagination cursor. Returns the page **after** this cursor (newer entries, ascending)   |
| `before`            | string  | ❌        | None             | Opaque pagination cursor. Returns the page **before** this cursor (older entries, descending) |

> **Cursors are opaque.** Treat `after` / `before` values as opaque tokens — obtain them from `pagination.cursor` or by following `links.next`, and do not construct or parse them yourself. `after` and `before` are mutually exclusive; supplying both returns `400 Bad Request`.

> **Deprecated (legacy):** The `since_id` and `before_id` parameters are still accepted during the migration window but are deprecated. `after` supersedes `since_id` (newer, ascending) and `before` supersedes `before_id` (older, descending). New integrations should use `after` / `before`.

#### 11.4. List Product Videos Response

**Success Response**: `200 OK`

| Field        | Type      | Nullable | Description                                                                  |
| ------------ | --------- | -------- | ---------------------------------------------------------------------------- |
| `videos`     | object\[] | ❌        | Array of video objects                                                       |
| `links`      | object    | ❌        | Pagination links (see the `links` Object table below)                        |
| `pagination` | object    | ❌        | Pagination state (see the `pagination` Object table below)                   |
| `paging`     | object    | ✅        | **Deprecated.** Legacy pagination object; use `links` + `pagination` instead |

**Video Object**

Each item uses the canonical [Video object](/firework-for-developers/api/videos.md#video-object), plus the product-listing-specific `hidden` field.

| Field                         | Type      | Nullable | Description                                                       |
| ----------------------------- | --------- | -------- | ----------------------------------------------------------------- |
| `id`                          | string    | ❌        | Encoded video ID                                                  |
| `access`                      | string    | ❌        | Video visibility: `"public"`, `"private"`, or `"unlisted"`        |
| `audio_disabled`              | boolean   | ❌        | Whether audio is disabled                                         |
| `caption`                     | string    | ✅        | Video title or caption                                            |
| `content_generation_type`     | string    | ✅        | `"aigc"` for AI-generated content; otherwise `null`               |
| `description`                 | string    | ✅        | Video description                                                 |
| `duration`                    | number    | ✅        | Duration in seconds                                               |
| `hashtags`                    | string\[] | ❌        | Hashtag strings                                                   |
| `archived_at`                 | string    | ✅        | ISO 8601 archive time                                             |
| `published_at`                | string    | ✅        | ISO 8601 publish time; a future value means scheduled publication |
| `is_published`                | boolean   | ❌        | Whether the video is currently published                          |
| `action_type`                 | string    | ✅        | CTA action type                                                   |
| `action_type_translation`     | string    | ✅        | Translated CTA display label                                      |
| `action_url`                  | string    | ✅        | CTA destination URL                                               |
| `action_custom_label`         | string    | ✅        | Custom CTA label                                                  |
| `product_ids`                 | string\[] | ❌        | Firework-encoded product IDs                                      |
| `variant_ids`                 | string\[] | ❌        | Firework-encoded product variant IDs                              |
| `custom_fields`               | object    | ❌        | Custom key-value metadata                                         |
| `display_social_attributions` | boolean   | ✅        | Whether social attribution is displayed                           |
| `external_media`              | object    | ✅        | Social media source metadata                                      |
| `thumbnail_url`               | string    | ✅        | CDN URL for the video thumbnail image                             |
| `watch_url`                   | string    | ❌        | Web URL where a viewer can watch the video                        |
| `video_posters`               | array     | ❌        | Video poster images; see Video Poster Schema below                |
| `hidden`                      | boolean   | ❌        | Whether the video is hidden in this product listing               |

**Video Poster Schema**

| Field               | Type    | Nullable | Description                                        |
| ------------------- | ------- | -------- | -------------------------------------------------- |
| `id`                | string  | ❌        | Encoded poster ID                                  |
| `url`               | string  | ❌        | CDN URL for the poster image                       |
| `aspect_ratio`      | string  | ✅        | Aspect ratio (e.g. `"9:16"`, `"16:9"`, `"1:1"`)    |
| `format`            | string  | ❌        | Image format (`"jpg"`, `"webp"`, `"gif"`, `"png"`) |
| `width`             | integer | ❌        | Image width in pixels                              |
| `height`            | integer | ❌        | Image height in pixels                             |
| `video_poster_type` | string  | ❌        | `"static"` or `"animated"`                         |
| `source`            | string  | ✅        | System that generated the poster                   |
| `external_id`       | string  | ✅        | Generating system's poster ID                      |
| `weight`            | number  | ✅        | Rotation weight from 0 to 1                        |

**`links` Object**

| Field  | Type   | Nullable | Description                                                                                                     |
| ------ | ------ | -------- | --------------------------------------------------------------------------------------------------------------- |
| `next` | string | ✅        | Relative path (beginning `/api/v1/...`) to the next page. `null` when there is no next page. Follow it verbatim |

> This endpoint is a forward-only feed, so `links` has no `prev` key.

**`pagination` Object**

| Field      | Type    | Nullable | Description                                                                               |
| ---------- | ------- | -------- | ----------------------------------------------------------------------------------------- |
| `cursor`   | string  | ✅        | Opaque cursor for the next page; pass it as the `before` parameter. `null` when exhausted |
| `has_more` | boolean | ❌        | `true` when another page exists right now                                                 |

> **Deprecated (legacy):** The `paging` object (with its `paging.next` URL, or `{}` when exhausted) is still returned during the migration window but is deprecated in favor of `links` + `pagination`. New integrations should ignore `paging`.

#### 11.5. List Product Videos Error Responses

| Status Code        | Description                                                                                                |
| ------------------ | ---------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`  | Missing `channel_id`, a non-integer `page_size`, a malformed cursor, or both `after` and `before` supplied |
| `401 Unauthorized` | Missing or invalid token                                                                                   |
| `403 Forbidden`    | Missing `products:read` scope or channel belongs to other business                                         |
| `404 Not Found`    | Product or channel not found                                                                               |

> **Authorization Note**: The `channel_id` must belong to the same business as the authenticated user or OAuth app. Attempting to access a channel from another business returns 403.

#### 11.6. Examples

**CURL Request**

```bash
curl -X GET "https://api.firework.com/api/v1/products/vWKDjg/videos?channel_id=z1xg8N" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Paginated Request (older entries)**

Pass the `pagination.cursor` from the previous response as `before` (or simply follow `links.next`):

```bash
curl -X GET "https://api.firework.com/api/v1/products/vWKDjg/videos?channel_id=z1xg8N&page_size=10&before=eyJiZWZvcmVfaWQiOiIyYWljQWwxSyJ9" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Paginated Request (newer entries)**

```bash
curl -X GET "https://api.firework.com/api/v1/products/vWKDjg/videos?channel_id=z1xg8N&page_size=10&after=eyJzaW5jZV9pZCI6IkR1eDl2eThtIn0" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

**Example Response**

```json
{
  "videos": [
    {
      "id": "Dux9vy8m",
      "caption": "Summer Collection Showcase",
      "content_generation_type": null,
      "description": "Check out our latest summer styles",
      "duration": 31.4,
      "hashtags": ["summer", "fashion", "newcollection"],
      "access": "public",
      "audio_disabled": false,
      "archived_at": null,
      "product_ids": ["vWKDjg"],
      "custom_fields": {},
      "thumbnail_url": "https://cdn.firework.com/medias/2026/3/18/abc123/540_960/thumb.jpg",
      "display_social_attributions": false,
      "external_media": null,
      "video_posters": [
        {
          "id": "PoSt42",
          "url": "https://cdn.firework.com/medias/2026/3/18/abc123/transcoded/poster-9x16.jpg",
          "aspect_ratio": "9:16",
          "format": "jpg",
          "width": 1080,
          "height": 1920,
          "video_poster_type": "static",
          "source": "api",
          "external_id": null,
          "weight": null
        }
      ],
      "hidden": false
    },
    {
      "id": "2aicAl1K",
      "caption": "How to Style This Look",
      "content_generation_type": null,
      "description": null,
      "duration": 18.75,
      "hashtags": [],
      "access": "public",
      "audio_disabled": false,
      "archived_at": null,
      "product_ids": ["vWKDjg"],
      "custom_fields": {},
      "thumbnail_url": null,
      "display_social_attributions": false,
      "external_media": null,
      "video_posters": [],
      "hidden": true
    }
  ],
  "links": {
    "next": "/api/v1/products/vWKDjg/videos?channel_id=z1xg8N&page_size=10&before=eyJiZWZvcmVfaWQiOiIyYWljQWwxSyJ9"
  },
  "pagination": {
    "cursor": "eyJiZWZvcmVfaWQiOiIyYWljQWwxSyJ9",
    "has_more": true
  },
  "paging": {
    "next": "/api/v1/products/vWKDjg/videos?channel_id=z1xg8N&before_id=2aicAl1K&page_size=10"
  }
}
```

To fetch the next page, follow `links.next` verbatim, or pass `pagination.cursor` as the `before` query parameter.

**Example Response (Last Page)**

```json
{
  "videos": [
    {
      "id": "9xKmPq2r",
      "caption": "Product Unboxing",
      "content_generation_type": null,
      "description": "First look at the product",
      "duration": 42.0,
      "hashtags": ["unboxing"],
      "access": "public",
      "audio_disabled": false,
      "archived_at": null,
      "product_ids": ["vWKDjg"],
      "custom_fields": {},
      "thumbnail_url": "https://cdn.firework.com/medias/2026/3/18/def456/540_960/thumb.jpg",
      "display_social_attributions": false,
      "external_media": null,
      "video_posters": [],
      "hidden": false
    }
  ],
  "links": { "next": null },
  "pagination": { "cursor": null, "has_more": false },
  "paging": {}
}
```

***

### 12. List Product Images

Retrieve published, non-archived library images that feature a product. This endpoint is part of the [Image API](/firework-for-developers/api/images.md) and uses image authorization rather than product authorization.

**Endpoint**: `GET /api/v1/products/{product_id}/images` **Authentication**: Bearer token required **Required Scope**: `images:read` **Feature**: `images`

#### 12.1. Request Headers

| Name            | Description                           | Required |
| --------------- | ------------------------------------- | -------- |
| `Authorization` | Bearer token: `Bearer {ACCESS_TOKEN}` | ✅        |

#### 12.2. Parameters

| Parameter             | Location | Type    | Required | Default | Description                        |
| --------------------- | -------- | ------- | -------- | ------- | ---------------------------------- |
| `product_id`          | Path     | string  | ✅        | None    | Encoded Firework product ID        |
| `include_unpublished` | Query    | boolean | ❌        | `false` | Include draft and scheduled images |
| `after`               | Query    | string  | ❌        | None    | Opaque forward cursor              |
| `before`              | Query    | string  | ❌        | None    | Opaque backward cursor             |
| `page_size`           | Query    | integer | ❌        | 10      | Number of images, maximum 100      |

Archived/deleted images and listings with `image_hidden: true` are always excluded. Unpublished and scheduled images are excluded unless `include_unpublished=true`. Results use each listing's `image_sort_position`, with unranked images last.

#### 12.3. Response

`200 OK` returns the standard image collection envelope (the item below is abbreviated):

```json
{
  "images": [
    {
      "id": "ImG7xQ",
      "title": "Summer outfit",
      "published_at": "2026-08-14T08:30:00.000000Z",
      "is_published": true,
      "archived_at": null,
      "products": [
        {
          "product_id": "vWKDjg",
          "product_unit_id": null,
          "product_sort_position": 1,
          "image_sort_position": 2,
          "image_hidden": false,
          "product_hidden": false,
          "bounding_box": null
        }
      ]
    }
  ],
  "links": {"next": null},
  "pagination": {"cursor": null, "has_more": false}
}
```

See the Image API's [Image Object](/firework-for-developers/api/images.md#41-image-object) for all fields.

#### 12.4. Error Responses

| Status Code            | Description                                                  |
| ---------------------- | ------------------------------------------------------------ |
| `400 Bad Request`      | Invalid pagination parameters                                |
| `401 Unauthorized`     | Missing or invalid token                                     |
| `402 Payment Required` | The business does not have the `images` feature              |
| `403 Forbidden`        | Missing `images:read` scope or inaccessible product business |
| `404 Not Found`        | Product or store not found                                   |

#### 12.5. Example

```bash
curl "https://api.firework.com/api/v1/products/vWKDjg/images?page_size=10" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"
```

***

### 13. Pagination

The [List Products](#5-list-products), [List Product Videos](#11-list-product-videos), and [List Product Images](#12-list-product-images) endpoints use the standard cursor-based `links` + `pagination` response envelope.

**How it works:**

* `after` returns the page **after** an opaque cursor (newer entries, ascending order).
* `before` returns the page **before** an opaque cursor (older entries, descending order).
* `after` and `before` are mutually exclusive; supplying both returns `400 Bad Request`.
* Without a cursor, results start from the newest entry (descending by ID).
* Cursor values are **opaque** — obtain them from `pagination.cursor` or by following `links.next`, and never construct or parse them yourself.
* To advance, follow `links.next` verbatim, or pass `pagination.cursor` back as the `before` parameter. When there are no more results, `links.next` and `pagination.cursor` are `null` and `pagination.has_more` is `false`.
* `page_size` controls items per page (default 10, range 1–100; values above the max are clamped).

> **Deprecated (legacy):** During the migration window, the List Product Videos endpoint also still accepts the legacy `since_id` / `before_id` query parameters and returns a legacy `paging` object (with a `paging.next` URL, or `{}` when exhausted). These are deprecated — `after` supersedes `since_id` (newer, ascending) and `before` supersedes `before_id` (older, descending). New integrations should use `after` / `before` with the `links` + `pagination` envelope and ignore `paging`.

**Pagination Examples**

```bash
# First page (no cursor needed)
GET /api/v1/products/vWKDjg/videos?channel_id=z1xg8N&page_size=10

# Older entries — pass pagination.cursor back as `before` (or just follow links.next)
GET /api/v1/products/vWKDjg/videos?channel_id=z1xg8N&page_size=10&before=eyJiZWZvcmVfaWQiOiIyYWljQWwxSyJ9

# Newer entries — pass an `after` cursor
GET /api/v1/products/vWKDjg/videos?channel_id=z1xg8N&page_size=10&after=eyJzaW5jZV9pZCI6IkR1eDl2eThtIn0
```

***

### 14. Product Identifiers

The `product_id` path parameter in Get Product, Update Product, Delete Product, the product-unit endpoints, and List Product Videos accepts multiple identifier types for flexibility. List Product Images currently requires the encoded Firework product ID. Supported product identifier types are:

* **Firework encoded product ID** — The internal Firework product identifier
* **External product ID** — Your system's product identifier
* **External product unit ID** — Your system's product variant/unit identifier
* **Product unit GTIN** — Global Trade Item Number (UPC, EAN, ISBN, etc.)
* **Product unit SKU** — Stock Keeping Unit
* **Product unit MPN** — Manufacturer Part Number
* **Product unit barcode** — Barcode value of the variant/unit

#### Product Lookup Rules

* The system will attempt to resolve the product using each identifier type in sequence
* If `business_store_id` is provided, the lookup is scoped to that specific store
* An encoded product ID derives its store from the product; if `business_store_id` is supplied, it must match
* For other identifiers, `business_store_id` may be omitted only when the authenticated business resolves to exactly one store. Multiple stores return `400 Bad Request` rather than guessing
* If the product cannot be found using any identifier type, a `404 Not Found` is returned

#### Identifier Examples

```bash
# Delete by Firework product ID
DELETE /api/v1/products/vWKDjg

# Delete by external product ID (with store scoping)
DELETE /api/v1/products/SHOE-001?business_store_id=y8PDj8

# List product videos by GTIN/UPC
GET /api/v1/products/071249656457/videos?channel_id=z1xg8N

# Delete by SKU (with store scoping)
DELETE /api/v1/products/SKU-SHOE-GR10?business_store_id=y8PDj8
```

***
