# Bulk Price Updates with Undo

> Run a WooCommerce sale campaign through Royal MCP Pro: dry-run preview, commit with a 72-hour undo safety net, verify on the storefront, roll back cleanly.

- Canonical: <https://royalplugins.com/support/royal-mcp-pro/bulk-price-updates-with-undo/>
- HTML version: <https://royalplugins.com/support/royal-mcp-pro/bulk-price-updates-with-undo/>

---

End-to-end walkthrough for running a WooCommerce sale campaign via MCP — the reference scenario is "run 12% off across 47 products for a weekend flash sale, roll back cleanly Monday morning." Same pattern works for seasonal sales, clearance events, dynamic pricing tests, and end-of-quarter markdowns. Dry-run preview, snapshot-per-product commit, 72-hour undo.

Why this over WooCommerce's built-in bulk edit

WC's built-in bulk edit modifies live prices in one click with no preview + no undo. If you fat-finger a percentage, the only path back is manual per-product restore or a database backup. `wc_bulk_price_update` gives you the dry-run preview PLUS a per-product snapshot BEFORE the write, so undo is one MCP call away for the next 72 hours.

## Prerequisites

- WooCommerce active
- Royal MCP Pro installed and licensed (see [Getting Started](https://royalplugins.com/support/royal-mcp-pro/getting-started/))
- MCP client connected to your site
- Cap: `manage_woocommerce` (umbrella check) + per-product `edit_post`
- **Strongly recommended**: a SiteVault snapshot before the commit step on any high-blast campaign (100+ products). The 72h undo is your primary net; SiteVault is belt-and-suspenders.

## The full campaign workflow

#### Identify the product set

Decide which products are in scope. Ask your AI to build the ID list from a category, tag, price range, or SKU pattern:

```
Get all products in category "Summer 2025 Collection" using wc_get_products. Give me the array of product IDs and their current regular_price + sale_price.
```

Or if you already have the IDs from a spreadsheet, skip this step and go to step 2.

#### Compute new prices (client-side, in the AI)

Have your AI apply the campaign math to each product and build the `updates` array:

```
For each of those products, compute a new sale_price at 12% off the regular_price rounded to .99 (e.g. regular $50 → sale $43.99). Build me the updates array for wc_bulk_price_update.
```

The output will look like:

```
updates: [
  { "product_id": 42, "sale_price": "43.99" },
  { "product_id": 108, "sale_price": "26.39" },
  { "product_id": 143, "sale_price": "17.59" },
  ...
]
```

#### Dry-run the price update

Call `wc_bulk_price_update` with `dry_run: true`. Nothing is written — you get back the list of products that would be touched, plus which would be skipped or errored:

```
Preview the price update with wc_bulk_price_update dry_run=true using the updates array above.
```

Response shape:

```
{
  "isError": false,
  "content": [
    { "type": "text", "text": "Updated prices on 47 products (0 skipped, 0 errors) [dry-run]." }
  ],
  "structuredContent": {
    "updated": [42, 108, 143, ...],
    "skipped": [],
    "errors": [],
    "dry_run": true,
    "updated_count": 47,
    "skipped_count": 0,
    "errors_count": 0
  }
}
```

If anything appears in `skipped` or `errors`, review before committing. Common reasons:

- `skipped: [{ id, reason: "insufficient_caps" }]` — the acting user doesn't have `edit_post` on that product
- `skipped: [{ id, reason: "no_price_fields" }]` — the update row didn't include `regular_price` OR `sale_price` (at least one is required)
- `errors: [{ id, reason: "not_found" }]` — product ID doesn't exist
- `errors: [{ id: 0, reason: "invalid_product_id" }]` — malformed row missing `product_id`

#### Commit the campaign

Once the dry-run looks clean, call the tool again with `dry_run: false`:

```
Commit the price update with wc_bulk_price_update dry_run=false using the same updates array.
```

What happens per product on the server:

- The tool snapshots the current `regular_price` and `sale_price` BEFORE mutating (line-level guarantee: the undo payload reflects true pre-state, not a race).
- If `regular_price` is in the row, `$product->set_regular_price()` is called.
- If `sale_price` is in the row, `$product->set_sale_price()` is called (passing `null` deletes the sale price and reverts to regular).
- `$product->save()` commits.
- `wc_delete_product_transients($pid)` fires so cached shop/category displays refresh.

The response includes a 72-hour undo token you'll need for step 5:

```
{
  "isError": false,
  "content": [{ "type": "text", "text": "Updated prices on 47 products (0 skipped, 0 errors)." }],
  "structuredContent": {
    "updated": [42, 108, 143, ...],
    "skipped": [],
    "errors": [],
    "dry_run": false,
    "updated_count": 47,
    "skipped_count": 0,
    "errors_count": 0
  },
  "undo": {
    "token": "<64-character-hex-token>",
    "expires_at": "2026-08-08T14:23:15Z",
    "ttl_hours": 72
  }
}
```

**Save the undo token somewhere durable.** Paste it into your project notes, a Slack DM to yourself, whatever. You'll need it for step 5 — and you have 72 hours before it expires.

#### Verify on the storefront

Load the actual shop / category pages in an incognito browser (bypass logged-in admin caching) and check:

- Sale prices render on product cards (regular price shown struck-through, sale price highlighted)
- Cart / checkout math uses the new prices
- Product structured data (view page source, look for `schema.org/Product`) reflects the new prices — important for Google Shopping / SERP snippets
- Any active FB/Google product catalog sync picks up the new prices on next sync (usually within 24hr)

If prices don't render but the tool response said success:

- Purge any page-cache plugin (WP Rocket, LiteSpeed Cache, W3 Total Cache) — the tool fires `wc_delete_product_transients` but page caches are a separate layer
- Purge CDN cache (Cloudflare, BunnyCDN, etc.)
- If using ForgeCache Pro, ask your AI to run `fc_purge_url` or `fc_clear_cache`

#### End the campaign — undo OR set-back

Two paths depending on how long the campaign ran:

#### Path A: Short campaign, still within 72h

Call `royal_mcp_undo_last_operation` with the token from step 4:

```
Undo the price campaign with royal_mcp_undo_last_operation using token <64-character-hex-token>.
```

The `undo_restore_product_meta` handler loops through every snapshotted product and restores the prior `regular_price` + `sale_price` per product exactly. One call, campaign fully reverted.

#### Path B: Long campaign, past the 72h window

The undo token has expired. Path back: re-run `wc_bulk_price_update` with the original prices, passing them explicitly. If you captured the original prices in step 1 (asking the AI for current prices at the start of the workflow), those values are still in your chat transcript. Re-issue the same tool call structure:

```
updates: [
  { "product_id": 42, "regular_price": "50.00", "sale_price": "" },
  { "product_id": 108, "regular_price": "30.00", "sale_price": "" },
  ...
]
```

Passing `sale_price: ""` or `sale_price: null` deletes the sale price and reverts the product to displaying its regular price only. This "re-run in reverse" is not as clean as the undo path but works past the TTL window.

## Multi-batch campaigns (100+ products)

The tool doesn't hard-cap the `updates` array size, but for portfolios above ~100 products consider batching for two reasons:

- **Undo granularity.** Each batch gets its own undo token. If batch 3 of 5 has a bad calculation, you can undo just that batch without touching batches 1, 2, 4, 5.
- **MCP client transport reliability.** Some MCP clients have loose timeouts on large single-response payloads. Batching keeps each response tight.

### Recommended batching pattern

1. Group products by category or by SKU prefix — whatever's semantically meaningful for your store
2. Run `wc_bulk_price_update` per group with dry\_run first, then commit
3. Verify each group's storefront section before starting the next
4. Keep a running list of undo tokens (one per batch) mapped to what each batch covered
5. At end-of-campaign, undo the batches in reverse order (5, 4, 3, 2, 1) so the storefront transitions cleanly

## Special cases

### Variable products

Passing a variable-product's parent ID to `wc_bulk_price_update` updates the parent's stored regular\_price / sale\_price fields, but WooCommerce derives the display price from variations at render time. For a real variable-product campaign, either pass variation IDs directly to `wc_bulk_price_update` (each variation is a separate post with its own ID and its own regular/sale prices), or use the free-tier `wc_batch_update_variations` tool which takes a parent product ID and variation-level update rows in one call.

### Percentage-off vs fixed-price

The tool takes literal price values, not percentages. Have your AI compute the percentage-off math client-side and pass the resulting prices in the `updates` array. Rounding is your call (round to `.99`, round to nearest integer, keep raw decimal — whatever your pricing psychology prefers).

### Scheduled sales

`wc_bulk_price_update` writes prices immediately. If you want prices to activate at a future date, use WooCommerce's built-in scheduled sale-price fields per product — this tool doesn't touch `_sale_price_dates_from` or `_sale_price_dates_to` meta. For scheduled bulk campaigns, cron-schedule two Royal MCP calls: one to set prices at start-time, one to restore at end-time.

### Site with WC Subscriptions

Existing subscription prices are calculated from the subscription's own recorded price, not the product's current price. Changing a product's `regular_price` or `sale_price` via this tool does NOT retroactively change what active subscribers pay on their next renewal — that's WC Subscriptions' by-design behavior. For new subscribers signing up during your campaign, they'll get the sale price locked into their subscription record.

## Still Stuck? Two-Step Support Path

If the price campaign isn't running the way you expected, work through these two steps in order.

### Step 1: Start with the Royal MCP Troubleshooting Guide

[**Royal MCP Troubleshooting — Start Here**](https://royalplugins.com/support/royal-mcp/troubleshooting-start-here/) covers MCP-layer issues.

For Pro-specific patterns:

- [Undo token errors](https://royalplugins.com/support/royal-mcp-pro/undo-token-errors/) — if step 5 fails or your token expired
- [Pro tool returned upgrade prompt with valid license](https://royalplugins.com/support/royal-mcp-pro/pro-tool-returned-upgrade-prompt/)

### Step 2: Email priority support

If you've worked through Start Here and the relevant Pro-specific doc and the issue still isn't resolved, email priority support from your purchase email address at **[support@royalplugins.com](mailto:support@royalplugins.com)**. Priority email support is included with your license — typical response within 24 hours. **Never include your license key in email**; we look it up from your purchase address.

What to include in your email

- **Your hosting provider**
- **Royal MCP Pro version** + **WooCommerce version** from WP Admin → Plugins
- **Whether HPOS (High-Performance Order Storage) is enabled** — check WP Admin → WooCommerce → Settings → Advanced → Features
- **WC Subscriptions version** if installed
- **Active caching plugins** (LiteSpeed Cache, WP Rocket, W3 Total Cache, SpeedyCache, ForgeCache, none)
- **Which MCP client**
- **The full updates array** you passed (or a redacted-to-shape sample if it contains sensitive pricing you'd rather not share — we just need to see the shape)
- **The full response** including `updated` / `skipped` / `errors` arrays
- **Whether step 5 (undo) was attempted** and what the undo response was
- **Screenshot of the Pro tool row in Audit Log** with View Details expanded

[← Divi Page Cloning](https://royalplugins.com/support/royal-mcp-pro/divi-page-cloning-workflow/)
[Site Launch Readiness →](https://royalplugins.com/support/royal-mcp-pro/site-launch-readiness/)
