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.testUse 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
searchoptional 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
messagewhen 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.