> 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 retrieve product-related video content on the Firework platform. This API supports full product lifecycle management — listing, retrieving, creating/updating, and deleting products — as well as retrieving videos tagged with a specific product for PDP (Product Detail Page) integration.

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 and product videos                                        |
| `products:write` | Write access (create, update, delete products). Implicitly grants `products:read` |

> 📖 **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 single product (by Firework encoded ID)  |
| `/api/v1/products`                     | POST   | `products:write` | Upsert (create or update) a product            |
| `/api/v1/products/{product_id}`        | DELETE | `products:write` | Delete a product                               |
| `/api/v1/products/{product_id}/videos` | GET    | `products:read`  | Retrieve videos tagged with a specific product |

***

### 4. Product Object Reference

The product object is returned by the List, Get, and Upsert endpoints. All IDs are Firework encoded IDs.

#### 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 the Custom CTA Object section below)                  |
| `business_store_id` | string    | ❌        | Encoded ID of the business store this product belongs to                                              |
| `images`            | object\[] | ❌        | Array of product images (see the Image Object section below)                                          |
| `units`             | object\[] | ❌        | Array of product variants/units (see the Unit Object section below)                                   |

#### 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 the Unit Option section below) |
| `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 below). 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 section.

| Field        | Type      | Nullable | Description                                                         |
| ------------ | --------- | -------- | ------------------------------------------------------------------- |
| `products`   | object\[] | ❌        | Array of product objects (see the Product Object Reference section) |
| `links`      | object    | ❌        | Pagination links (see the Pagination Envelope section below)        |
| `pagination` | object    | ❌        | Pagination state (see the Pagination Envelope section 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 its Firework encoded product ID.

> **Note**: Unlike Delete Product and List Product Videos, this endpoint resolves **only** the Firework encoded product ID (the `id` returned by the Upsert and List Products endpoints). It does **not** accept alternate identifiers (external ID, SKU, GTIN, MPN, barcode) and does **not** accept a `business_store_id` query parameter — the product is looked up directly by its encoded ID and the caller's business access is verified against that product's store.

**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 | ✅        | Firework encoded product ID (the `id` returned by Upsert / List Products) |

#### 6.3. 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 section.

#### 6.4. Get Product Error Responses

| Status Code        | Description                                               |
| ------------------ | --------------------------------------------------------- |
| `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 given encoded ID                |

#### 6.5. Examples

**CURL Request — By Firework ID**

```bash
curl -X GET "https://api.firework.com/api/v1/products/vWKDjg" \
  -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 the Custom CTA Input section below). Send `null` to clear an existing CTA.                                      |
| `product_images`    | object\[] | ❌        | `[]`           | Array of product images (see the Image Input section below)                                                                                              |
| `product_units`     | object\[] | ❌        | `[]`           | Array of product variants/units (see the Unit Input section below)                                                                                       |

**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 `images` array. The request body does not accept a `position` field for images.

**Unit Input**

| Field            | Type      | Required | Default | Description                                              |
| ---------------- | --------- | -------- | ------- | -------------------------------------------------------- |
| `external_id`    | string    | ❌        | None    | External identifier for the unit/variant                 |
| `name`           | string    | ✅        | None    | Display name of the unit/variant                         |
| `price`          | string    | ✅        | None    | Current price as a string (e.g., `"129.99"`)             |
| `original_price` | string    | ❌        | 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 shape described in the Product Object Reference section.

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

* If `units` is included in an upsert for an existing product, the existing unit collection is replaced by the provided `units` array.
* If `images` is included in an upsert for an existing product, the existing image collection is replaced by the provided `images` array.
* Image-to-unit associations are rebuilt from `images[].unit_external_ids` against the `units` included in the same request. To safely update variant images and their associations, send `images` and `units` together.

#### 7.3. Upsert Product Response

**Success Response**: `201 Created`

The response returns the full product object as described in the Product Object Reference section.

```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 (e.g., invalid currency, missing unit fields, malformed nested `units` or `images`, invalid `custom_cta` — `title` not in enum, `title` without `url` or vice versa, `url` exceeds 4000 characters) |

#### 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",
    "product_units": [
      {
        "name": "Default",
        "price": "50.00",
        "url": "https://example.com/products/gift-card-50",
        "options": []
      }
    ]
  }'
```

**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"
    },
    "product_units": [
      {
        "name": "Default",
        "price": "129.99",
        "url": "https://shop.example.com/classic-running-shoe",
        "options": []
      }
    ]
  }'
```

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
    },
    "product_units": [
      {
        "name": "Default",
        "price": "129.99",
        "url": "https://shop.example.com/classic-running-shoe",
        "options": []
      }
    ]
  }'
```

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

#### 8.1. Request Headers

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

#### 8.2. Path Parameters

| Parameter    | Type   | Required | Description                                                    |
| ------------ | ------ | -------- | -------------------------------------------------------------- |
| `product_id` | string | ✅        | Product identifier (see the Product Identifiers section below) |

#### 8.3. Query Parameters

| Parameter           | Type   | Required | Description                                                                                                   |
| ------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------- |
| `business_store_id` | string | ❌        | Encoded business store ID to scope the lookup. If omitted, uses the first store of the authenticated business |

#### 8.4. Delete Product Response

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

No response body. The product has been successfully deleted.

#### 8.5. Delete Product Error Responses

| Status Code                | Description                                                                                               |
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`          | Malformed `business_store_id`, or `business_store_id` is required (the token maps to multiple businesses) |
| `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)                                            |

#### 8.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"
}
```

***

### 9. 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)

#### 9.1. Request Headers

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

#### 9.2. Path Parameters

| Parameter    | Type   | Required | Description                                                    |
| ------------ | ------ | -------- | -------------------------------------------------------------- |
| `product_id` | string | ✅        | Product identifier (see the Product Identifiers section below) |

#### 9.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                                                  |
| `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`.

#### 9.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**

| Field                         | Type      | Nullable | Description                                                                 |
| ----------------------------- | --------- | -------- | --------------------------------------------------------------------------- |
| `id`                          | string    | ❌        | Encoded video ID                                                            |
| `caption`                     | string    | ❌        | Video title/caption                                                         |
| `description`                 | string    | ✅        | Video description                                                           |
| `hashtags`                    | string\[] | ❌        | Array of hashtag strings (empty if none provided)                           |
| `access`                      | string    | ❌        | Video visibility level (`"public"`, `"private"`, `"unlisted"`)              |
| `audio_disabled`              | boolean   | ❌        | Whether audio is disabled for the video (default: `false`)                  |
| `archived_at`                 | string    | ✅        | ISO 8601 timestamp when video was archived                                  |
| `product_ids`                 | string\[] | ❌        | Array of Firework encoded product IDs                                       |
| `custom_fields`               | object    | ❌        | Custom key-value metadata                                                   |
| `thumbnail_url`               | string    | ✅        | CDN URL for the video thumbnail image (540x960)                             |
| `display_social_attributions` | boolean   | ❌        | Whether social attribution is displayed                                     |
| `external_media`              | object    | ✅        | Social media source metadata (see External Media Schema)                    |
| `video_posters`               | array     | ❌        | Array of video poster images (empty if none). See Video Poster Schema below |
| `hidden`                      | boolean   | ❌        | Whether the video is hidden in the product listing                          |

**Video Poster Schema**

| Field          | Type    | Nullable | Description                                        |
| -------------- | ------- | -------- | -------------------------------------------------- |
| `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                             |

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

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

#### 9.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",
      "description": "Check out our latest summer styles",
      "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": [
        {
          "url": "https://cdn.firework.com/medias/2026/3/18/abc123/transcoded/poster-9x16.jpg",
          "aspect_ratio": "9:16",
          "format": "jpg",
          "width": 1080,
          "height": 1920
        }
      ],
      "hidden": false
    },
    {
      "id": "2aicAl1K",
      "caption": "How to Style This Look",
      "description": null,
      "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",
      "description": "First look at the product",
      "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": {}
}
```

***

### 10. Pagination

Both list endpoints — List Products and List Product Videos — share the same cursor-based pagination scheme, exposed through the standard `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
```

***

### 11. Product Identifiers

The `product_id` path parameter in the Delete Product and List Product Videos endpoints accepts multiple identifier types for flexibility. (The Get Product endpoint is the exception — it resolves **only** the Firework encoded product ID.) The supported 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
* If `business_store_id` is omitted, the system uses the first business store of the authenticated business
* 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
```
