Why the Batch API and not a loop of synchronous calls
Writing a script that loops over 900 products and calls chat/completions 900 times is the fastest way to hit a rate limit, time out on a shared host, and burn through tokens with no audit trail. The OpenAI Batch API works differently: you upload one JSONL file containing every request, the job runs asynchronously (a 24-hour completion window), and you poll for the result. It is roughly half the price per token compared to interactive calls, and it survives a dropped SSH connection because nothing is held open.
For WooCommerce this maps neatly onto a review workflow. Generation is slow and cheap, so it belongs in a batch. Approval is fast and needs a human, so it belongs in the admin. The WP-CLI command does the boring part; a capability-gated screen does the judgement part.
The critical design rule: never write generated text straight into post_content. Stage it in post meta, let an editor approve it, and only then promote it — via wp_update_post(), so WordPress runs its own sanitisation and revision pipeline.
Prerequisites
- WordPress 6.4 or later and WooCommerce 8+. HPOS is fine; we only touch
post_content, which stays on the posts table. - WP-CLI 2.7+ installed and working over SSH or a local terminal. Verify with
wp cli version. - An OpenAI organisation account with a project API key that has the
filesandbatchesscopes enabled. - PHP 8.0+ with the
curlextension. Every mainstream host has it; check withphp -m | grep curl. - A staging copy of the database. Run the first pass there, not on production.
Architecture at a glance
- enqueue — select products with an empty description, build a
.jsonlfile, upload it, create the batch, record the batch ID. - poll — check each recorded batch; when it completes, download the output file and write each response to
_wc_bd_pendingmeta. - Admin screen — list products with pending copy, preview it, approve (promote to
post_content) or discard.
Step 1 — Keep the API key out of the database
Storing the key in an option means it round-trips through the database, shows up in any migration, and gets exported by backup plugins. A constant in wp-config.php that reads from an environment variable keeps it out of the DB and out of version control. Define it above the /* That's all, stop editing! */ line, and never commit the file.
// wp-config.php — above "That's all, stop editing".
// Set OPENAI_API_KEY in your shell/server environment, not in version control.
define( 'WC_OPENAI_API_KEY', getenv( 'OPENAI_API_KEY' ) ?: '' );
// Hard-stop the command if the key is missing, rather than failing mid-batch.
if ( defined( 'WP_CLI' ) && WP_CLI && ( ! defined( 'WC_OPENAI_API_KEY' ) || '' === WC_OPENAI_API_KEY ) ) {
WP_CLI::warning( 'WC_OPENAI_API_KEY is not set. WC batch generation will refuse to run.' );
}
Read the key only inside the command class, through a single getter, so you have one place to add a filter later if you move to a secret manager. Do not echo it in log lines — the OpenAI error responses never contain it, but your own WP_CLI::log() calls easily could.
Step 2 — The enqueue command: select, build JSONL, upload
The selection query is the part people get wrong. WooCommerce stores the long description in post_content, so a product is a candidate when post_content is empty or whitespace. You cannot filter on that with a meta_query, so pull IDs with a reasonable criteria first and trim in PHP. Also exclude anything already queued with NOT EXISTS on the batch meta, otherwise a second run re-bills the same products.
Every JSONL line needs a stable custom_id — the only field OpenAI echoes back. product-{ID} is enough and is trivially reversible.
private function build_jsonl( array $ids, string $path ): int {
$handle = fopen( $path, 'w' );
if ( ! $handle ) {
WP_CLI::error( 'Cannot open ' . $path . ' for writing.' );
}
$written = 0;
foreach ( $ids as $id ) {
$product = wc_get_product( $id );
if ( ! $product ) {
continue;
}
$categories = wp_strip_all_tags( wc_get_product_category_list( $id, ', ' ) );
$attributes = [];
foreach ( $product->get_attributes() as $attribute ) {
if ( $attribute->get_visible() ) {
$attributes[] = $attribute->get_name() . ': ' . implode( ', ', $attribute->get_options() );
}
}
$attributes = wp_strip_all_tags( implode( '; ', $attributes ) );
$prompt = sprintf(
'Write a single-paragraph product description of about 110 words for "%1$s". Categories: %2$s. Key attributes: %3$s. Use British English, no exclamation marks, no marketing cliches, no pricing claims.',
$product->get_name(),
$categories ?: 'uncategorised',
$attributes ?: wp_strip_all_tags( $product->get_short_description() ) ?: 'none provided'
);
$row = [
'custom_id' => 'product-' . $id,
'method' => 'POST',
'url' => '/v1/chat/completions',
'body' => [
'model' => 'gpt-4o-mini',
'temperature' => 0.4,
'max_tokens' => 400,
'messages' => [
[
'role' => 'system',
'content' => 'You write plain-text WooCommerce product copy. Return the description only, with no headings, labels, or commentary.',
],
[
'role' => 'user',
'content' => $prompt,
],
],
],
];
$line = wp_json_encode( $row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
if ( false === fwrite( $handle, $line . "\n" ) ) {
WP_CLI::warning( 'Skipped product ' . $id . ' — write failed.' );
continue;
}
$written++;
}
fclose( $handle );
return $written;
}
Note the temperature of 0.4. Product copy should be consistent across a catalogue; the default of 1.0 produces wildly different tones between a mug and a mug.
The upload endpoint requires multipart/form-data, which wp_remote_post() does not handle well. Use cURL directly for this one call — it is the honest solution and every host that runs WooCommerce has the extension.
private function upload_batch_file( string $path ): string {
$key = $this->get_api_key();
$ch = curl_init( 'https://api.openai.com/v1/files' );
curl_setopt_array( $ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 180,
CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . $key ],
CURLOPT_POSTFIELDS => [
'purpose' => 'batch',
'file' => new CURLFile( $path, 'application/jsonl', 'wc-descriptions.jsonl' ),
],
] );
$raw = curl_exec( $ch );
$status = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
$data = json_decode( (string) $raw, true );
if ( 200 !== $status || empty( $data['id'] ) ) {
$this->log( 'File upload failed: ' . $raw );
WP_CLI::error( 'Upload failed with HTTP ' . $status . '. See the WC log for the body.' );
}
return $data['id'];
}
Once you have the file ID, create the batch with a plain JSON POST to /v1/batches — body input_file_id, endpoint: "/v1/chat/completions", completion_window: "24h". Store the returned batch ID in an option array, not in post meta; a batch maps to many products and you want a single registry to iterate.
Step 3 — Poll, ingest, and log
Polling is deliberately dumb: loop over the registry, GET each batch, act on the status. Batch statuses move through validating, in_progress, finalizing, completed, and can end at failed, expired, or cancelled. Only completed gets ingested; remove the batch from the registry only after ingest succeeds, so a crash mid-ingest is retryable.
public function poll(): void {
$registry = (array) get_option( 'wc_bd_batches', [] );
if ( ! $registry ) {
WP_CLI::warning( 'No batches on record.' );
return;
}
foreach ( $registry as $index => $batch ) {
$data = $this->api_get( 'batches/' . $batch['id'] );
$status = $data['status'] ?? 'unknown';
$this->log( sprintf( 'Batch %s is %s (%s/%s done)', $batch['id'], $status,
$data['request_counts']['completed'] ?? 0,
$data['request_counts']['total'] ?? 0
) );
if ( 'completed' !== $status ) {
continue;
}
$body = $this->api_get_raw( 'files/' . $data['output_file_id'] . '/content' );
$this->ingest( $body );
unset( $registry[ $index ] );
update_option( 'wc_bd_batches', array_values( $registry ) );
}
}
private function ingest( string $body ): void {
$staged = 0;
foreach ( preg_split( '/\r?\n/', $body ) as $line ) {
$row = json_decode( $line, true );
if ( ! is_array( $row ) || empty( $row['custom_id'] ) ) {
continue;
}
$post_id = (int) str_replace( 'product-', '', $row['custom_id'] );
if ( ! $post_id || 'product' !== get_post_type( $post_id ) ) {
continue;
}
$text = $row['response']['body']['choices'][0]['message']['content'] ?? '';
if ( '' === trim( $text ) ) {
continue;
}
update_post_meta( $post_id, '_wc_bd_pending', wp_kses_post( wpautop( $text ) ) );
update_post_meta( $post_id, '_wc_bd_batch_id', sanitize_text_field( $row['id'] ?? '' ) );
update_post_meta( $post_id, '_wc_bd_generated_at', current_time( 'mysql' ) );
$staged++;
}
$this->log( sprintf( 'Staged %d descriptions as pending review.', $staged ) );
}
private function log( string $message ): void {
if ( function_exists( 'wc_get_logger' ) ) {
wc_get_logger()->info( $message, [ 'source' => 'wc-bd' ] );
}
WP_CLI::log( $message );
}
wp_kses_post() plus wpautop() preserves paragraph breaks while stripping anything the model hallucinates — including stray <script> tags or Markdown fences. This is why sanitising at ingest time, not approval time, matters: the staged meta is already safe to render in the admin preview.
Step 4 — Dry-run mode and the CLI surface
The dry-run flag writes the JSONL to wp-content/uploads/wc-bd/ and prints a one-line preview of the first three prompts, then exits before any network call. This is how you validate prompt wording without spending money. Guard the directory with an index.php and a .htaccess deny, or write to the system temp dir instead.
# Build and upload a batch of up to 100 empty descriptions
wp wc-batch enqueue --limit=100 --user=admin
# Same selection, no upload — inspect the JSONL first
wp wc-batch enqueue --limit=100 --dry-run
# Check batch progress and stage finished results
wp wc-batch poll
# Run poll every five minutes from cron, logging elsewhere
*/5 * * * * cd /var/www/site && wp wc-batch poll >> /var/log/wc-batch.log 2>&1
Inside enqueue(), call WP_CLI::confirm( 'Upload the batch? This incurs API cost.' ) unless --yes is passed. WP-CLI sets --yes automatically in non-interactive contexts, so cron still works without a prompt.
Step 5 — The admin review screen
Approval must clear three gates: the edit_products capability, a nonce, and a second look at the product’s current state (someone may have written the description manually while the batch ran). Promoting through wp_update_post() rather than a direct $wpdb write means revisions are created, caches are flushed, and WooCommerce’s own hooks fire.
add_action( 'admin_menu', static function (): void {
add_submenu_page(
'woocommerce',
'Description Review',
'Description Review',
'edit_products',
'wc-bd-review',
'wc_bd_render_review'
);
} );
function wc_bd_render_review(): void {
if ( ! current_user_can( 'edit_products' ) ) {
wp_die( esc_html__( 'You do not have permission to review product copy.', 'wc-bd' ) );
}
if ( isset( $_POST['wc_bd_product'], $_POST['wc_bd_action'], $_POST['wc_bd_nonce'] ) ) {
check_admin_referer( 'wc_bd_review', 'wc_bd_nonce' );
$id = absint( $_POST['wc_bd_product'] );
$action = sanitize_key( wp_unslash( $_POST['wc_bd_action'] ) );
if ( 'product' !== get_post_type( $id ) ) {
wp_die( esc_html__( 'Invalid product.', 'wc-bd' ) );
}
if ( 'approve' === $action ) {
$staged = get_post_meta( $id, '_wc_bd_pending', true );
if ( $staged ) {
wp_update_post( [
'ID' => $id,
'post_content' => wp_kses_post( $staged ),
] );
}
}
delete_post_meta( $id, '_wc_bd_pending' );
wc_delete_product_transients( $id );
echo '<div class="notice notice-success"><p>Updated.</p></div>';
}
$pending = get_posts( [
'post_type' => 'product',
'post_status' => 'any',
'posts_per_page' => 25,
'meta_key' => '_wc_bd_pending',
'fields' => 'ids',
] );
echo '<div class="wrap"><h2>Pending Descriptions</h2>';
echo '<p>' . esc_html( sprintf( '%d products awaiting review.', count( $pending ) ) ) . '</p>';
foreach ( $pending as $id ) {
$text = get_post_meta( $id, '_wc_bd_pending', true );
echo '<div style="border:1px solid #ccd0d4;padding:12px;margin:0 0 12px">';
echo '<strong>' . esc_html( get_the_title( $id ) ) . '</strong>';
echo '<div style="margin:8px 0">' . wp_kses_post( $text ) . '</div>';
echo '<form method="post">';
wp_nonce_field( 'wc_bd_review', 'wc_bd_nonce' );
echo '<input type="hidden" name="wc_bd_product" value="' . esc_attr( (string) $id ) . '" />';
submit_button( 'Approve', 'primary', 'wc_bd_action', false, [ 'value' => 'approve' ] );
echo ' ';
submit_button( 'Discard', 'secondary', 'wc_bd_action', false, [ 'value' => 'discard' ] );
echo '</form></div>';
}
echo '</div>';
}
The preview uses wp_kses_post() on output because the staged meta is stored already-sanitised — belt and braces. The nonce action is named wc_bd_review rather than the product ID, so one nonce value works across the whole list and you are not regenerating form markup per row.
Troubleshooting
- Batch stuck on
validatingfor more than an hour. Almost always a malformed JSONL line — one bad row invalidates the whole file. Re-run--dry-runand check the last line ends with a single newline and no trailing comma. - Batch fails with
input_file_mismatch. You passed the wrongendpointstring. It must be exactly/v1/chat/completions, including the leading slash. - Ingest stages zero rows. Inspect the raw output body. Errors are inside
response.body.error, notchoices— a single product with a 400-character title can trip a model refusal and you need to see it. - WSOD on the review screen. A function name collision with another snippet. The code above defines global functions; wrap everything in a
function_exists()check, or move the render callback to a class method. - Products re-queued after approval. The
_wc_bd_batch_idmeta is only written on ingest. If you discard before poll runs, the product returns to the pool — intentional, but worth documenting for editors. - 401 from the files endpoint. A project key without
filesscope. Create a new key in the project settings rather than the legacy organisation-wide key.
Next steps
- Add a
wc-batch cancel <batch_id>subcommand hittingPOST /v1/batches/{id}/cancelso a badly-worded prompt can be stopped without waiting 24 hours. - Extend the prompt builder to read passed-in
--category=filters, then re-run per category with a different tone instruction. - Log token usage from
response.body.usage.total_tokensinto a custom table, so you can attribute cost per product edit. - Wire the approve step into a WordPress revision note via
wp_update_post()‘spost_excerpt— an audit trail editors can see in the classic editor. - Read the WP-CLI command handbook and nonce documentation before you extend the admin screen.