Why a suggestion engine beats an autopilot
Most dynamic pricing plugins for WooCommerce are rule engines: if stock exceeds forty units and the product sits in the clearance category, cut ten per cent. Rules are cheap, predictable and completely blind. They cannot notice that a competitor slashed a rival SKU on Tuesday, that a review went live on Wednesday, or that velocity halved on Thursday. A large language model can absorb those messy, semi-structured inputs and return a defensible number with a written rationale you can show a merchandiser.
What a model cannot do is guarantee the number is sane. So the extension you build here is deliberately split in two. The suggestion engine is probabilistic and allowed to be wrong. The application layer is deterministic: it clamps, rounds, logs and waits for a human. That separation is the whole design, and it is what makes the feature safe enough to ship on a live store.
Prerequisites
- WordPress 6.5 or newer, WooCommerce 8.5 or newer, PHP 8.1 or newer.
- An LLM provider that supports JSON-schema structured output. If yours does not, you can still follow along but you will need a fallback parser (covered in troubleshooting).
- Unit cost stored per product in the
_wpt_h_unit_costmeta key. Without a cost figure the margin floor is guesswork and the guardrails are decorative. - A competitor price source. This tutorial assumes a helper that returns a keyed array of competitor name to price, whether you scrape it, upload a CSV or pull it from a supplier feed.
- WP-CLI access so you can trigger scheduled jobs without waiting a day.
The pipeline in one glance
- Collect signals: price, cost, stock, thirty-day units sold, competitor prices.
- Send them to the model with a strict response schema.
- Validate the JSON and reject anything malformed.
- Clamp the result between a margin floor and a percentage ceiling.
- Write the suggestion to an audit table with status
pending. - A human approves, or a scheduled job applies it when confidence is high.
Everything below is a single plugin file plus a small admin script. No Composer, no build step.
Step 1 — Scaffold the plugin and an audit table
You need a permanent record of every number the model proposed and every number you actually applied. When a merchandiser asks in six weeks why a product dropped from 49.00 to 41.50, the audit table is the answer. It also gives you the data to measure whether the model is any good, which is the only way to justify the API spend.
<?php
register_activation_hook( __FILE__, 'wpt_h_install_audit_table' );
function wpt_h_install_audit_table(): void {
global $wpdb;
$table = $wpdb->prefix . 'wpt_h_price_audit';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
old_price DECIMAL(12,4) NOT NULL,
new_price DECIMAL(12,4) NOT NULL,
confidence DECIMAL(4,3) NOT NULL DEFAULT 0,
rationale TEXT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY product_id (product_id),
KEY status (status)
) $charset;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
Note the DECIMAL columns rather than FLOAT. Money and confidence scores should never round-trip through binary floating point in the database.
Step 2 — Collect the signals that actually move price
An LLM is only as useful as the numbers you hand it. The single most valuable signal is sales velocity, and the fastest place to get it is the wc_order_product_lookup index table, which WooCommerce populates regardless of whether High-Performance Order Storage is enabled. Querying it directly avoids instantiating hundreds of order objects.
<?php
function wpt_h_collect_signals( int $product_id ): array {
global $wpdb;
$product = wc_get_product( $product_id );
if ( ! $product ) {
return [];
}
$since = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );
$units = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE( SUM( product_qty ), 0 )
FROM {$wpdb->prefix}wc_order_product_lookup
WHERE product_id = %d AND date_created >= %s",
$product_id,
$since
)
);
return [
'name' => $product->get_name(),
'sku' => $product->get_sku(),
'currency' => get_woocommerce_currency(),
'current_price' => (float) $product->get_price(),
'unit_cost' => (float) get_post_meta( $product_id, '_wpt_h_unit_cost', true ),
'stock_qty' => $product->get_stock_quantity(),
'units_30d' => $units,
'velocity_day' => round( $units / 30, 2 ),
'competitors' => wpt_h_competitor_prices( $product->get_sku() ),
];
}
Two practical notes. First, cache the competitor array in a transient keyed by SKU so a nightly scan of two hundred products does not hammer a scraper. Second, if unit_cost comes back as zero, skip the product entirely rather than sending a misleading payload — a model told the cost is nothing will happily recommend a price that loses money.
Step 3 — Ask the model for a strict shape
Free-text responses are the reason most LLM integrations break in production. Use the provider’s structured output mode so the response is guaranteed to match a schema, and set temperature low. Pricing is a task where you want the same answer twice, not creative flair.
<?php
function wpt_h_request_suggestion( array $signals ): ?array {
$key = defined( 'WPT_H_LLM_KEY' ) ? WPT_H_LLM_KEY : '';
if ( '' === $key || empty( $signals ) ) {
return null;
}
$schema = [
'type' => 'object',
'properties' => [
'suggested_price' => [ 'type' => 'number' ],
'confidence' => [ 'type' => 'number' ],
'rationale' => [ 'type' => 'string' ],
'tiers' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'min_qty' => [ 'type' => 'integer' ],
'discount' => [ 'type' => 'number' ],
],
'required' => [ 'min_qty', 'discount' ],
],
],
],
'required' => [ 'suggested_price', 'confidence', 'rationale' ],
];
$response = wp_remote_post(
'https://api.openai.com/v1/chat/completions',
[
'timeout' => 45,
'headers' => [
'Authorization' => 'Bearer ' . $key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( [
'model' => 'gpt-4o-mini',
'temperature' => 0.2,
'response_format' => [
'type' => 'json_schema',
'json_schema' => [ 'name' => 'pricing', 'strict' => true, 'schema' => $schema ],
],
'messages' => [
[
'role' => 'system',
'content' => 'You are a retail pricing analyst. Return only JSON matching the schema. Never propose a price below unit_cost. Justify the number in one sentence.',
],
[ 'role' => 'user', 'content' => wp_json_encode( $signals ) ],
],
] ),
]
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$raw = $body['choices'][0]['message']['content'] ?? '';
$data = json_decode( $raw, true );
return is_array( $data ) ? $data : null;
}
Store the API key as a constant in wp-config.php, never in the options table where it lands in every database export. If you must use an option, mark it non-autoloaded and encrypt it at rest.
Step 4 — Clamp before you trust
This is the step people skip, and it is the one that stops a hallucinated 0.01 from wiping out your margin. The model proposes; PHP decides. A floor derived from unit cost protects profitability, a ceiling expressed as a percentage of the current price protects against absurd jumps, and rounding to the store’s price decimals keeps the value valid for WooCommerce’s CRUD layer.
<?php
function wpt_h_clamp( int $product_id, array $suggestion ): array {
$product = wc_get_product( $product_id );
$current = (float) $product->get_price();
$cost = (float) get_post_meta( $product_id, '_wpt_h_unit_cost', true );
$floor = max( $cost * 1.15, $current * 0.85 );
$ceiling = $current * 1.15;
$raw = (float) ( $suggestion['suggested_price'] ?? $current );
$price = round( max( $floor, min( $ceiling, $raw ) ), wc_get_price_decimals() );
return [
'price' => $price,
'clamped' => abs( $price - $raw ) > 0.001,
'confidence' => min( 1, max( 0, (float) ( $suggestion['confidence'] ?? 0 ) ) ),
'rationale' => sanitize_text_field( $suggestion['rationale'] ?? '' ),
];
}
Fifteen per cent per cycle is a deliberately conservative default. It means a genuinely mispriced product takes a few days to walk to its target instead of lurching there overnight and confusing repeat customers. Log the clamped flag — a high clamp rate is your earliest warning that the prompt or the signals need work.
Step 5 — Expose it through a permissioned REST route
The admin button needs an endpoint, and that endpoint needs a real capability check. edit_product maps to the product’s own meta capability, so a shop manager can price their own catalogue but a subscriber cannot touch anything.
<?php
add_action( 'rest_api_init', function (): void {
register_rest_route(
'wpt-h/v1',
'/suggest/(?P<id>\d+)',
[
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => static function ( WP_REST_Request $request ) {
return current_user_can( 'edit_product', (int) $request['id'] );
},
'args' => [
'id' => [
'validate_callback' => static fn( $value ) => ctype_digit( (string) $value ),
'sanitize_callback' => 'absint',
],
],
'callback' => static function ( WP_REST_Request $request ) {
$id = (int) $request['id'];
$signals = wpt_h_collect_signals( $id );
if ( empty( $signals ) ) {
return new WP_Error( 'wpt_h_no_signals', 'Product not found.', [ 'status' => 404 ] );
}
$raw = wpt_h_request_suggestion( $signals );
if ( null === $raw ) {
return new WP_Error( 'wpt_h_llm_failed', 'No usable suggestion returned.', [ 'status' => 502 ] );
}
$result = wpt_h_clamp( $id, $raw );
wpt_h_log_suggestion( $id, $result, 'pending' );
return rest_ensure_response( $result );
},
]
);
} );
Because the route is registered with WP_REST_Server::CREATABLE, WordPress enforces the X-WP-Nonce header automatically for cookie-authenticated requests. You do not need to check the nonce by hand, and you should not try.
Step 6 — Wire up the admin button
Use wp.apiFetch rather than hand-rolled fetch. It carries the REST nonce, handles the root URL, and normalises error objects. Enqueue it with the correct dependency so the middleware is registered.
const { apiFetch } = wp;
const button = document.getElementById( 'wpt-h-suggest' );
const output = document.getElementById( 'wpt-h-result' );
button.addEventListener( 'click', async () => {
button.disabled = true;
output.textContent = 'Asking the model…';
try {
const result = await apiFetch( {
path: `/wpt-h/v1/suggest/${ button.dataset.productId }`,
method: 'POST',
} );
output.textContent = '';
const line = document.createElement( 'p' );
line.textContent = `${ result.price } (confidence ${ result.confidence }) — ${ result.rationale }`;
output.appendChild( line );
} catch ( error ) {
output.textContent = error.message || 'Request failed.';
} finally {
button.disabled = false;
}
} );
Note what is not happening: the rationale is inserted with textContent, never innerHTML. Model output is untrusted input. If you need formatting, build DOM nodes explicitly or render through a block with a proper RichText component.
Step 7 — Schedule nightly scans with Action Scheduler
WooCommerce bundles Action Scheduler, which is far more reliable than wp_cron for anything that costs money per request. Queue one action per product so a single API timeout does not abort the whole batch.
<?php
add_action( 'init', function (): void {
if ( function_exists( 'as_next_scheduled_action' ) && ! as_next_scheduled_action( 'wpt_h_nightly_scan' ) ) {
as_schedule_recurring_action( time() + 600, DAY_IN_SECONDS, 'wpt_h_nightly_scan' );
}
} );
add_action( 'wpt_h_nightly_scan', function (): void {
$ids = wc_get_products( [ 'limit' => 200, 'return' => 'ids', 'status' => 'publish' ] );
foreach ( $ids as $id ) {
as_enqueue_async_action( 'wpt_h_price_one', [ 'product_id' => $id ], 'wpt-h' );
}
} );
Cap the batch. Two hundred products at roughly one request each is a predictable monthly bill; five thousand is not, and it will also trip provider rate limits mid-scan.
Step 8 — Show the rationale on the front end with block bindings
If you want a “why this price” note under the product title, register a block bindings source instead of filtering the_content. It works inside a Query Loop and stays compatible with block themes and theme.json styling.
<?php
add_action( 'init', function (): void {
register_block_bindings_source(
'wpt-h/pricing-note',
[
'label' => __( 'Pricing note', 'wpt-h' ),
'get_value_callback' => static function ( array $args ) {
$post_id = $args['postId'] ?? 0;
$note = get_post_meta( $post_id, '_wpt_h_price_note', true );
return $note ? esc_html( $note ) : '';
},
]
);
} );
Bind a Paragraph block to wpt-h/pricing-note in the Site Editor and the note follows the product automatically.
Cost, security and rate limits
- Budget guard. Keep a running counter in an option and stop the scan when the daily request ceiling is hit.
- Never expose the key. The REST route proxies the call server-side; the browser never sees a token.
- Sanitise on the way in and out.
sanitize_text_field()on the rationale,absint()on IDs, and a numeric cast on every price. - Log failures. Use
wc_get_logger()with sourcewpt-h-pricingso failed calls surface in WooCommerce > Status > Logs. - Default to pending. Auto-apply only when confidence exceeds a threshold you have validated against real data.
Troubleshooting
The REST route returns 401 rest_forbidden
Either the nonce is stale or the user lacks edit_product. Confirm the script is enqueued with wp-api-fetch as a dependency, and check that you are testing as a shop manager rather than an administrator on a multisite subsite where the capability differs.
The model returns prose instead of JSON
Your provider probably ignored response_format. Fall back to stripping triple-backtick fences and running json_decode() with a strict check, then reject anything that fails rather than guessing.
Every suggestion is clamped to the ceiling
That usually means _wpt_h_unit_cost is empty, so the floor collapses to 85 per cent of current price and the model’s higher figure gets cut. Backfill costs before running another scan.
Velocity is always zero
The lookup table only records completed and processing orders. If your store marks orders complete manually and the queue is backed up, the last thirty days can look empty. Widen the window to ninety days or include on-hold in your own query.
Requests time out during the nightly scan
Lower the batch size, raise the timeout argument to 60, and confirm Action Scheduler is running — check WooCommerce > Status > Scheduled Actions for a growing pending count.
Next steps
- Extend the schema to variation-level pricing so a size run can be priced independently.
- Feed the audit table back into the prompt as recent history, so the model sees its own last three decisions.
- Run a two-week holdout: apply suggestions to half the catalogue and compare gross margin against the control group.
- Add a discount-tier block that writes
min_qtyanddiscountpairs into a WooCommerce coupon or a tiered pricing table. - Cache suggestions in a transient for an hour so refreshing the admin page does not bill you twice.
The pattern generalises: gather deterministic signals, ask a model for a structured opinion, clamp it with code you control, and keep a human in the loop until the audit data proves the model deserves autonomy.
Leave a Reply