---
title: "Show products from your store"
description: "Build a product search widget with a WordPress demo API and a Custom API action. Show matching products with prices, stock badges, and links in the conversation."
---

:::note[Plan]
Actions are available on the **Starter** plan and above.
:::

Connect a product API to a widget so visitors can browse products inside chat. In this tutorial, you create a WordPress plugin with eight sample products, build a **Product results** widget, and connect a **Product search** action.

You need a WordPress site where you can add a PHP file, an agent in Chatvox, and a public HTTPS address for the API. The products and links are demonstration data, not a connection to WooCommerce or a real store inventory.

1. **Add the demo product API**

    Create `wp-content/mu-plugins/` in your WordPress installation if it does not exist. Save this complete file as `wp-content/mu-plugins/chatvox-demo-products.php`. WordPress loads plugins in this folder automatically.

    ```php title="chatvox-demo-products.php"
    <?php
    /**
     * Plugin Name: Chatvox demo products
     * Description: Fake product catalog over the REST API, for testing Chatvox Custom API actions and widgets.
     *
     * GET /wp-json/chatvox-demo/v1/products?search=shirt&limit=3
     * GET /wp-json/chatvox-demo/v1/products/{id}
     *
     * Responses: {"success": true, "data": {...}} so the Chatvox defaults
     * (success path "success", data path "data") work unchanged.
     */

    if (! defined('ABSPATH')) {
        exit;
    }

    function chatvox_demo_products(): array
    {
        $image = fn (string $seed): string => "https://picsum.photos/seed/{$seed}/160";

        return [
            ['id' => 101, 'name' => 'Linen shirt', 'price' => '$48.00', 'category' => 'Shirts', 'in_stock' => true, 'rating' => 4.6, 'image' => $image('linen-shirt'), 'url' => 'https://example.com/products/linen-shirt'],
            ['id' => 102, 'name' => 'Oxford shirt', 'price' => '$56.00', 'category' => 'Shirts', 'in_stock' => true, 'rating' => 4.4, 'image' => $image('oxford-shirt'), 'url' => 'https://example.com/products/oxford-shirt'],
            ['id' => 103, 'name' => 'Canvas tote', 'price' => '$32.00', 'category' => 'Bags', 'in_stock' => false, 'rating' => 4.8, 'image' => $image('canvas-tote'), 'url' => 'https://example.com/products/canvas-tote'],
            ['id' => 104, 'name' => 'Leather backpack', 'price' => '$129.00', 'category' => 'Bags', 'in_stock' => true, 'rating' => 4.7, 'image' => $image('leather-backpack'), 'url' => 'https://example.com/products/leather-backpack'],
            ['id' => 105, 'name' => 'Wool socks', 'price' => '$14.00', 'category' => 'Accessories', 'in_stock' => true, 'rating' => 4.2, 'image' => $image('wool-socks'), 'url' => 'https://example.com/products/wool-socks'],
            ['id' => 106, 'name' => 'Running sneakers', 'price' => '$95.00', 'category' => 'Shoes', 'in_stock' => true, 'rating' => 4.5, 'image' => $image('running-sneakers'), 'url' => 'https://example.com/products/running-sneakers'],
            ['id' => 107, 'name' => 'Rain jacket', 'price' => '$110.00', 'category' => 'Jackets', 'in_stock' => false, 'rating' => 4.3, 'image' => $image('rain-jacket'), 'url' => 'https://example.com/products/rain-jacket'],
            ['id' => 108, 'name' => 'Denim jacket', 'price' => '$89.00', 'category' => 'Jackets', 'in_stock' => true, 'rating' => 4.6, 'image' => $image('denim-jacket'), 'url' => 'https://example.com/products/denim-jacket'],
        ];
    }

    add_action('rest_api_init', function (): void {
        register_rest_route('chatvox-demo/v1', '/products', [
            'methods' => 'GET',
            'permission_callback' => '__return_true',
            'args' => [
                'search' => ['type' => 'string', 'default' => ''],
                'limit' => ['type' => 'integer', 'default' => 3, 'minimum' => 1, 'maximum' => 8],
            ],
            'callback' => function (WP_REST_Request $request): WP_REST_Response {
                $search = strtolower(trim((string) $request['search']));
                // Match any meaningful word, singular or plural; "all products" lists everything.
                $ignored = ['all', 'any', 'product', 'products', 'item', 'items', 'show', 'me', 'find', 'the', 'some', 'list', 'your', 'you', 'have', 'do', 'what', 'in', 'stock'];
                $words = array_values(array_diff(preg_split('/[^a-z0-9]+/', $search, -1, PREG_SPLIT_NO_EMPTY), $ignored));
                $words = array_map(fn (string $word): string => rtrim($word, 's'), $words);
                $products = array_values(array_filter(
                    chatvox_demo_products(),
                    function (array $product) use ($words): bool {
                        $haystack = strtolower($product['name'].' '.$product['category']);
                        foreach ($words as $word) {
                            if ($word !== '' && str_contains($haystack, $word)) {
                                return true;
                            }
                        }

                        return $words === [];
                    },
                ));

                return new WP_REST_Response(['success' => true, 'data' => [
                    'query' => $search,
                    'count' => count($products),
                    'products' => array_slice($products, 0, (int) $request['limit']),
                ]]);
            },
        ]);

        register_rest_route('chatvox-demo/v1', '/products/(?P<id>\d+)', [
            'methods' => 'GET',
            'permission_callback' => '__return_true',
            'callback' => function (WP_REST_Request $request): WP_REST_Response {
                foreach (chatvox_demo_products() as $product) {
                    if ($product['id'] === (int) $request['id']) {
                        return new WP_REST_Response(['success' => true, 'data' => ['product' => $product]]);
                    }
                }

                return new WP_REST_Response(['success' => false, 'message' => 'Product not found.'], 404);
            },
        ]);
    });
    ```

    The API address must be public. Chatvox refuses private addresses such as `localhost` and local `.test` hosts. For local testing, expose your site with a tunnel, for example:

    ```sh
    cloudflared tunnel --url https://your-site.test
    ```

    Use the public HTTPS URL printed by the tunnel in place of `https://your-public-site.example` below. A quick tunnel URL changes when the tunnel restarts; update the API connection's base URL when it changes.

2. **Check both endpoints**

    Search by product name or category:

    ```sh
    curl 'https://your-public-site.example/wp-json/chatvox-demo/v1/products?search=jacket'
    ```

    Expected response:

    ```json
    {
      "success": true,
      "data": {
        "query": "jacket",
        "count": 2,
        "products": [
          {
            "id": 107,
            "name": "Rain jacket",
            "price": "$110.00",
            "category": "Jackets",
            "in_stock": false,
            "rating": 4.3,
            "image": "https://picsum.photos/seed/rain-jacket/160",
            "url": "https://example.com/products/rain-jacket"
          },
          {
            "id": 108,
            "name": "Denim jacket",
            "price": "$89.00",
            "category": "Jackets",
            "in_stock": true,
            "rating": 4.6,
            "image": "https://picsum.photos/seed/denim-jacket/160",
            "url": "https://example.com/products/denim-jacket"
          }
        ]
      }
    }
    ```

    Omit `search` or leave it empty to match the whole catalog. The default `limit` is 3; use `limit=8` to return all eight products. `count` reports all matches before the limit, so it can be larger than the returned list. The tutorial action keeps the default limit.

    Fetch one product by ID:

    ```sh
    curl 'https://your-public-site.example/wp-json/chatvox-demo/v1/products/108'
    ```

    Expected response:

    ```json
    {
      "success": true,
      "data": {
        "product": {
          "id": 108,
          "name": "Denim jacket",
          "price": "$89.00",
          "category": "Jackets",
          "in_stock": true,
          "rating": 4.6,
          "image": "https://picsum.photos/seed/denim-jacket/160",
          "url": "https://example.com/products/denim-jacket"
        }
      }
    }
    ```

    An unknown ID returns HTTP 404 with this response:

    ```json
    { "success": false, "message": "Product not found." }
    ```

3. **Create the Product results widget**

    Go to your agent, then **Build** > **Widgets**. Select **Create widget** and name it `Product results`. In **Fields**, add:

    | Field | Type | Description |
    | --- | --- | --- |
    | `query` | **Text** | What the visitor searched for |
    | `count` | **Number** | How many products matched |
    | `products` | **List** | id, name, price, in_stock, rating, image, url |

    In **Functions**, add these two functions. Leave **After it runs** set to **None** for both.

    | Name | Type | Value |
    | --- | --- | --- |
    | `openProduct` | **Open link** | URL: `{{url}}` |
    | `askForHelp` | **Send message** | Message: `Can you help me choose between these?` |

    The View button passes each product URL as an additional input named `url`. The help button sends a message as the visitor.

    In **Code**, paste the complete widget:

    ```jsx title="Product results"
    <Card size="md" status={{ text: "Product search", icon: "search" }}>
      <Title value={`Results for "${query}"`} size="sm" />
      <Caption value={`${count} found`} />
      <Divider />
      <ListView gap={3}>
        {products.map((product) => (
          <ListViewItem gap={3} align="center">
            <Image src={product.image} alt={product.name} size={56} radius="md" />
            <Col gap={1} flex={1}>
              <Text value={product.name} weight="semibold" />
              <Row gap={2} align="center">
                <Text value={product.price} size="sm" />
                <Badge
                  label={product.in_stock ? "In stock" : "Out of stock"}
                  color={product.in_stock ? "success" : "warning"}
                  variant="soft"
                />
              </Row>
            </Col>
            <Button
              label="View"
              variant="outline"
              size="sm"
              onClickAction={{
                functionName: "openProduct",
                additionalInputs: { url: product.url },
              }}
            />
          </ListViewItem>
        ))}
      </ListView>
      <Button
        label="Help me choose"
        variant="ghost"
        onClickAction={{ functionName: "askForHelp" }}
      />
    </Card>
    ```

    In the **Default** tab, paste the API response's `data` object, without the outer `success` and `data` keys:

    ```json
    {
      "query": "jacket",
      "count": 2,
      "products": [
        {
          "id": 107,
          "name": "Rain jacket",
          "price": "$110.00",
          "category": "Jackets",
          "in_stock": false,
          "rating": 4.3,
          "image": "https://picsum.photos/seed/rain-jacket/160",
          "url": "https://example.com/products/rain-jacket"
        },
        {
          "id": 108,
          "name": "Denim jacket",
          "price": "$89.00",
          "category": "Jackets",
          "in_stock": true,
          "rating": 4.6,
          "image": "https://picsum.photos/seed/denim-jacket/160",
          "url": "https://example.com/products/denim-jacket"
        }
      ]
    }
    ```

    Select **Add example**, name it `No results`, and use:

    ```json
    {
      "query": "umbrella",
      "count": 0,
      "products": []
    }
    ```

    Use the preview to check both examples. **No results** shows a heading, `0 found`, and the help button, with no product rows. Named examples only affect the preview. The default example also provides fallback data when the action inputs and API response have no value for a declared field.

    Select **Save**.

4. **Create an API connection and action**

    First, save the API details. Go to **Build** > **API connections**, select **Create connection**, and enter:

    | Setting | Value |
    | --- | --- |
    | **Name** | Product demo |
    | **Base URL** | `https://your-public-site.example/wp-json/chatvox-demo/v1` |

    Replace the example host with your public site or tunnel address. The base URL includes the WordPress API path; the action adds the endpoint path.

    Next, go to **Build** > **Actions**, select **Create action**, and choose **Custom API**. Use these settings:

    | Setting | Value |
    | --- | --- |
    | **Action name** | Product search |
    | **What it does** | Searches the store catalog by product name or category and returns matching products with price and stock. |
    | **When to use** | When the visitor wants to find, browse, list or compare products, or asks what is available, prices, or whether something is in stock. |
    | **While it runs** | Searching products… |
    | **API connection** | Product demo |
    | **Method** | `GET` |
    | **Path and query** | `/products` |
    | **Send fields as** | **Query params** |
    | **Timeout (ms)** | `8000` |
    | **Response format** | **JSON** |
    | **Success path** | `success` |
    | **Message path** | `message` |
    | **Data path** | `data` |
    | **Widget** | Product results |

    Under **Input fields**, select **Add field** and enter:

    | Setting | Value |
    | --- | --- |
    | **Label** | Search |
    | **Key** | `search` |
    | **Type** | **Text** |
    | **Description** | Product name or category, such as jacket or shirt. Leave empty to list all products. |
    | **Required** | Off |

    Keep the API response available to the AI so it can use the product details in its reply. The widget receives `query`, `count`, and `products` from the mapped API data. It does not need matching action input fields for those response fields.

    In the **Test** card, enter `jacket` and select **Send test request**. Check the returned data, then save the action. Confirm the action is on.

5. **Try product searches in the Playground**

    Go to **Playground** and try these messages:

    | Message | What to check |
    | --- | --- |
    | Show me jackets. | The card lists Rain jacket and Denim jacket, their prices, and their different stock badges. |
    | Show me all products. | The action runs without asking for a search term. The API matches eight products but returns its first three with the default limit. |
    | Do you have umbrellas? | The card shows `0 found` and no product rows. |

    While the request runs, the visitor sees `Searching products…`. After a successful JSON response, the card appears under the agent reply. **View** opens the demo product link. **Help me choose** sends `Can you help me choose between these?` as the visitor's next message.

    Expand the action steps under the Playground reply. Check that **Product search** succeeded and review **Sent** and **Received**. The exact reply wording can vary; the product data comes from your API.

    Go to **Activity** > **Logs** and open the conversation. The reply includes its action steps and a read-only copy of the card labeled **Shown to the visitor**. This lets you inspect the data and widget that accompanied that reply.

You now have a product search action that calls your API and shows results in a chat card. Replace the fake catalog and product links with your store data when you adapt the example.

## Tips

- Write **When to use** around the visitor's intent: finding, browsing, listing, or comparing products.
- Make `search` optional so “show me all products” works without a follow-up question. Increase the API limit if you want the full catalog returned.
- Use **Message path** for human-readable API messages. This plugin has no success message, but its single-product endpoint returns `message` when a product is not found.
- Keep widget fields minimal so visitors receive only what the card needs. This demo includes IDs and ratings for experimentation; remove unused product properties from your API response when you adapt it.
