Skip to content
Docs
Esc
↑↓navigate↵open⌘Jpreview
On this page

Show products from your store

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.

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.

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

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.

Check both endpoints

Search by product name or category:

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

Expected response:

{
  "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:

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

Expected response:

{
  "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:

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

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:

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

{
  "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:

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

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.

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.