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

# Search Products

> Search for products across merchant catalogs using keyword-based queries with optional filters

Search for products across merchant catalogs using keyword-based queries. Use filters to narrow results by domain, price, availability, and variant attributes.

## Authentication

This endpoint supports two authentication methods:

<Tabs>
  <Tab title="Browser Session">
    For client-side applications (browser, mobile). See [Browser Session Authentication](/api-reference/authentication/browser-session).

    ```bash theme={null}
    curl -X POST https://api.firmly.work/api/v1/discovery/search \
      -H "x-firmly-authorization: YOUR_ACCESS_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{"query": "shoes"}'
    ```
  </Tab>

  <Tab title="Server-to-Server">
    For backend services acting on behalf of devices. See [Server-to-Server Authentication](/api-reference/authentication/server-to-server).

    ```bash theme={null}
    curl -X POST https://api.firmly.work/api/v1/discovery/search \
      -H "x-firmly-authorization: YOUR_S2S_SECRET" \
      -H "x-firmly-device-id: DEVICE_ID" \
      -H "Content-Type: application/json" \
      -d '{"query": "shoes"}'
    ```
  </Tab>
</Tabs>

## Request Body

<ParamField body="query" type="string" required>
  Search keyword(s). Required for all search requests.
</ParamField>

<ParamField body="filters" type="object">
  Optional filters to narrow search results.

  <Expandable title="Filter Object">
    <ParamField body="filters.domains" type="string[]">
      Array of merchant domains to search (e.g., `["benchmademodern.com", "merchant2.com"]`). When omitted, all merchants are searched.
    </ParamField>

    <ParamField body="filters.min_price" type="integer">
      Minimum price filter in cents (e.g., `100000` for $1,000.00, `9999` for $99.99). Must be an integer.
    </ParamField>

    <ParamField body="filters.max_price" type="integer">
      Maximum price filter in cents (e.g., `500000` for \$5,000.00). Must be an integer.
    </ParamField>

    <ParamField body="filters.in_stock" type="boolean">
      Filter to only show in-stock products. Set to `true` to enable.
    </ParamField>

    <ParamField body="filters.variants" type="array">
      Array of variant filters. Each filter specifies an option name and allowed values. Use the [Options endpoint](/api-reference/discovery/options) to discover available options and values.

      <Expandable title="Variant Filter Object">
        <ParamField body="filters.variants[].option" type="string" required>
          The variant option name (e.g., `"color"`, `"size"`, `"material"`).
        </ParamField>

        <ParamField body="filters.variants[].values" type="string[]" required>
          Array of allowed values for this option (e.g., `["Blue", "Black"]`). Uses exact string matching.
        </ParamField>
      </Expandable>
    </ParamField>
  </Expandable>
</ParamField>

<ParamField body="page_size" type="number" default="20">
  Number of results per page. Maximum: `100`
</ParamField>

## Response

<ResponseField name="products" type="array">
  Array of product objects matching the search criteria. Returns empty array `[]` when no results found (HTTP 200).

  <Expandable title="Product Object">
    <ResponseField name="base_sku" type="string">
      Base SKU identifier for the product
    </ResponseField>

    <ResponseField name="title" type="string">
      Product title
    </ResponseField>

    <ResponseField name="handle" type="string">
      Product handle/slug
    </ResponseField>

    <ResponseField name="description" type="string">
      Product description
    </ResponseField>

    <ResponseField name="pdp_url" type="string">
      Full URL to the product detail page on the merchant's site
    </ResponseField>

    <ResponseField name="domain" type="string">
      Merchant domain (e.g., `merchant.com`)
    </ResponseField>

    <ResponseField name="domain_name" type="string">
      Merchant display name
    </ResponseField>

    <ResponseField name="price_range" type="object">
      Price range for the product (accounts for variant pricing). All prices in USD cents.

      <Expandable title="Price Range Object">
        <ResponseField name="min" type="integer">
          Minimum price across all variants in cents (e.g., `249999` = \$2,499.99 USD). Always an integer, never a string or decimal.
        </ResponseField>

        <ResponseField name="max" type="integer">
          Maximum price across all variants in cents. Always an integer, never a string or decimal.
        </ResponseField>
      </Expandable>
    </ResponseField>

    <ResponseField name="has_available_variants" type="boolean">
      `true` if the product has at least one variant currently in stock. Always a boolean (`true`/`false`), never a string.
    </ResponseField>

    <ResponseField name="images" type="array">
      Array of product images

      <Expandable title="Image Object">
        <ResponseField name="url" type="string">
          URL of the image
        </ResponseField>

        <ResponseField name="alt" type="string">
          Alt text for the image
        </ResponseField>
      </Expandable>
    </ResponseField>

    <ResponseField name="variants" type="array">
      Array of product variants with pricing and availability
    </ResponseField>

    <ResponseField name="variant_option_values" type="array">
      Available variant options (sizes, colors, etc.)
    </ResponseField>

    <ResponseField name="tags" type="array">
      Product tags for categorization
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="page_size" type="number">
  Number of results returned in this page
</ResponseField>

<ResponseField name="time_taken_ms" type="number">
  Time taken to execute the search query in milliseconds
</ResponseField>

<ResponseField name="next_page" type="string">
  Full URL to fetch the next page of results. Use this URL directly with your authorization header. `null` when there are no more results.
</ResponseField>

## Error Responses

| Status Code | Error                   | Description                                                    |
| ----------- | ----------------------- | -------------------------------------------------------------- |
| `400`       | `InvalidRequest`        | Missing required `query` parameter or invalid parameter values |
| `401`       | `Unauthorized`          | Missing or invalid `x-firmly-authorization` header             |
| `401`       | `TokenExpired`          | Authentication token has expired. Refresh and retry.           |
| `403`       | `Forbidden`             | Token does not have permission for this operation              |
| `429`       | `RateLimitExceeded`     | Too many requests. Check `Retry-After` header.                 |
| `500`       | `ErrorStoreUnavailable` | Search service is temporarily unavailable                      |
| `503`       | `ServiceUnavailable`    | System maintenance in progress                                 |

<Note>
  **No results is not an error**: When no products match your search criteria, the API returns HTTP `200` with an empty `products` array. This is standard REST behavior—`404` is reserved for "resource not found" (e.g., invalid endpoints), not "no results found".
</Note>

<RequestExample>
  ```bash Basic Search (Browser Session) theme={null}
  curl --request POST \
    --url 'https://api.firmly.work/api/v1/discovery/search' \
    --header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \
    --header 'Content-Type: application/json' \
    --data '{
      "query": "leather sofa"
    }'
  ```

  ```bash Basic Search (Server-to-Server) theme={null}
  curl --request POST \
    --url 'https://api.firmly.work/api/v1/discovery/search' \
    --header 'x-firmly-authorization: YOUR_S2S_SECRET' \
    --header 'x-firmly-device-id: user-12345' \
    --header 'Content-Type: application/json' \
    --data '{
      "query": "leather sofa"
    }'
  ```

  ```bash Search with Filters theme={null}
  curl --request POST \
    --url 'https://api.firmly.work/api/v1/discovery/search' \
    --header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \
    --header 'Content-Type: application/json' \
    --data '{
      "query": "sofa",
      "filters": {
        "domains": ["benchmademodern.com"],
        "min_price": 100000,
        "max_price": 500000,
        "in_stock": true,
        "variants": [
          { "option": "color", "values": ["Brown", "Tan"] }
        ]
      },
      "page_size": 10
    }'
  ```

  ```bash Search Multiple Merchants theme={null}
  curl --request POST \
    --url 'https://api.firmly.work/api/v1/discovery/search' \
    --header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \
    --header 'Content-Type: application/json' \
    --data '{
      "query": "sofa",
      "filters": {
        "domains": ["merchant1.com", "merchant2.com", "merchant3.com"]
      },
      "page_size": 20
    }'
  ```

  ```bash Pagination theme={null}
  curl --request POST \
    --url 'https://api.firmly.work/api/v1/discovery/search' \
    --header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \
    --header 'Content-Type: application/json' \
    --data '{
      "query": "leather sofa",
      "cursor": "<cursor_from_previous_response>",
      "page_size": 20
    }'
  ```
</RequestExample>

<ResponseExample>
  ```json Success Response theme={null}
  {
    "products": [
      {
        "base_sku": "12345",
        "title": "Modern Leather Sofa",
        "handle": "modern-leather-sofa",
        "description": "A beautiful modern leather sofa perfect for any living room.",
        "pdp_url": "https://benchmademodern.com/products/modern-leather-sofa",
        "domain": "benchmademodern.com",
        "price_range": {
          "min": 249999,
          "max": 319999
        },
        "has_available_variants": true,
        "images": [
          {
            "url": "https://cdn.benchmademodern.com/images/modern-leather-sofa.jpg",
            "alt": "Modern Leather Sofa"
          }
        ],
        "variants": [],
        "variant_option_values": [],
        "tags": ["leather", "sofa", "modern", "living-room"]
      }
    ],
    "page_size": 20,
    "time_taken_ms": 45,
    "next_page": "https://api.firmly.work/api/v1/discovery/search?query=leather+sofa&page_size=20&cursor=eyJzY29yZSI6MTIuNSwiaWQiOiJhYmMxMjMifQ"
  }
  ```

  ```json Last Page (no more results) theme={null}
  {
    "products": [...],
    "page_size": 20,
    "time_taken_ms": 38,
    "next_page": null
  }
  ```

  ```json Empty Results (HTTP 200) theme={null}
  {
    "products": [],
    "page_size": 20,
    "time_taken_ms": 12,
    "next_page": null
  }
  ```

  ```json Error Response (HTTP 401) theme={null}
  {
    "error": "Unauthorized",
    "message": "Missing or invalid authorization header"
  }
  ```
</ResponseExample>

## Usage Notes

<Note>
  **Domain authorization**: Search results are limited to merchant domains associated with your account. Requests for unauthorized domains will return empty results. Contact your account manager to request access to additional merchants.
</Note>

<Note>
  **Domain filtering**: Use the `filters.domains` array to search specific merchants:

  * Single merchant: `"domains": ["benchmademodern.com"]`
  * Multiple merchants: `"domains": ["merchant1.com", "merchant2.com", "merchant3.com"]`
  * All merchants: omit the `domains` field entirely
</Note>

<Note>
  **Variant filtering**: Use the `filters.variants` array to filter by product attributes like color, size, or material. Each variant filter specifies an option name and an array of allowed values. Use the [Options endpoint](/api-reference/discovery/options) to discover available options and values.
</Note>

<Note>
  **Filter matching**: Variant filters use exact string matching. The values must match exactly as stored in the product data (e.g., `"Blue"` will not match `"blue"` or `"Navy"`).
</Note>

<Note>
  **Pricing**: All prices are integers in USD cents (not dollars). `"min_price": 100000` filters for $1,000.00+, and `price_range.min: 249999` means $2,499.99 USD. To display: `(price / 100).toFixed(2)`.
</Note>

<Note>
  **Data Types**: `price_range.min`, `price_range.max` are always integers in cents (e.g., `249999` for \$2,499.99). `has_available_variants` is always a boolean (`true` or `false`). These are never returned as strings.
</Note>

<Note>
  **Why cents?** Integer cents avoid floating-point precision issues (e.g., `0.1 + 0.2 !== 0.3` in JavaScript). This is the industry standard used by Stripe, PayPal, and other payment APIs.
</Note>

## Example: Search Implementation

```javascript theme={null}
// Search with filters
const searchProducts = async (query, options = {}) => {
  const body = { query };

  // Build filters object
  const filters = {};
  if (options.domains) filters.domains = options.domains;
  if (options.minPrice) filters.min_price = options.minPrice;
  if (options.maxPrice) filters.max_price = options.maxPrice;
  if (options.inStock) filters.in_stock = true;
  if (options.variants) filters.variants = options.variants;

  if (Object.keys(filters).length > 0) {
    body.filters = filters;
  }

  if (options.pageSize) body.page_size = options.pageSize;
  if (options.cursor) body.cursor = options.cursor;

  const response = await fetch(
    'https://api.firmly.work/api/v1/discovery/search',
    {
      method: 'POST',
      headers: {
        'x-firmly-authorization': authToken,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    }
  );

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error || 'Search failed');
  }

  return response.json();
};

// Usage - prices in cents (100000 = $1,000.00, 500000 = $5,000.00)
const results = await searchProducts('leather sofa', {
  domains: ['benchmademodern.com', 'merchant2.com'],
  minPrice: 100000,
  maxPrice: 500000,
  inStock: true,
  variants: [
    { option: 'color', values: ['Brown', 'Tan'] },
    { option: 'size', values: ['Large', 'Sectional'] }
  ],
  pageSize: 20
});

// Check if there are results (empty array is valid, not an error)
if (results.products.length === 0) {
  console.log('No products found matching your criteria');
} else {
  console.log(`Found ${results.products.length} products`);

  // Client-side sorting by price (low to high)
  const sortedByPrice = results.products.sort(
    (a, b) => a.price_range.min - b.price_range.min
  );

  // Display price: convert cents to dollars
  const displayPrice = (cents) => `$${(cents / 100).toFixed(2)}`;
  console.log(displayPrice(results.products[0].price_range.min)); // e.g., "$2,499.99"
}

// Pagination - use cursor from previous response
if (results.next_page) {
  // Extract cursor from next_page URL or use it directly
  const nextPageUrl = new URL(results.next_page);
  const cursor = nextPageUrl.searchParams.get('cursor');

  const nextResults = await searchProducts('leather sofa', {
    cursor,
    pageSize: 20
  });
  console.log(`Loaded ${nextResults.products.length} more products`);
}
```
