Script a bulk change safely

Change thousands of records from a shell script with the revenexx CLI — page through a list, write a plan you can review, apply it one row at a time, back off on a rate limit, and resume or undo without starting over.

This guide is for the developer who has to change a lot of records at once and cannot afford to do it twice. The example moves 3,000 products from one tax class to another with the CLI. By the end you have two small scripts: one that plans the change into a file you can read, and one that applies it row by row, survives a rate limit, and can be re-run or reversed.

The pieces already exist: output formats and exit codes, the products reference and non-interactive use. This page puts them in the order that keeps a bad run small.

The short answer: list into a plan file, review it, apply it from the file while recording each success, and treat the files, not your memory, as the record of what was changed and what is left.

The commands do not have a dry-run flag (as of 2026-10). The plan file is the dry run: nothing is written until step 3, and step 3 writes only what is in the plan.

Before you start

  • Use a staging tenant first. A tax class drives the VAT rate that prices resolve, so a wrong run changes what buyers pay. Run both scripts against staging, then production.
  • Use a key that can read and update products, and as little else as your key setup allows. See API keys.
  • Set the tenant and key in the environment, and pass the tenant on every call. The key in REVENEXX_API_KEY beats a key saved by login. The tenant does not work that way: a tenant someone saved with tenants use beats REVENEXX_TENANT. So every CLI call below carries --tenant "$REVENEXX_TENANT", which beats a saved tenant (CLI 0.5.0 or later), and the plan file name, the confirm prompt and the real target all name the same tenant.
Environment
export REVENEXX_TENANT=acme
export REVENEXX_API_KEY=rvxk_...

You need jq and bash. Pin the CLI to an exact version of 0.5.0 or later (npm install -g @revenexx/cli@<version>) so a re-run next week behaves like this one.

The two scripts below are files: save each one under the name in its tab, in the same folder, and make them executable with chmod +x plan.sh apply.sh.

1. Plan: page through the list into a file

products list returns at most 200 rows per call (a larger --limit is clamped, not refused) and reports page.total. Ask for the full envelope with --json and page until you have passed the total:

plan.sh
#!/usr/bin/env bash
# usage: ./plan.sh   (writes plan-<tenant>-<timestamp>.jsonl)
set -euo pipefail

# Tax class is free text in the API. Use the values your tenant really uses.
FROM=standard
TO=reduced
plan="plan-${REVENEXX_TENANT:?set REVENEXX_TENANT}-$(date +%Y%m%d-%H%M%S).jsonl"
: > "$plan"

offset=0
skipped=0
total=1   # replaced by the real total after the first call
while [ "$offset" -lt "$total" ]; do
  page=$(revenexx products list --tenant "$REVENEXX_TENANT" \
    --tax-class "$FROM" --order id --limit 200 --offset "$offset" --json)

  # An unknown filter is dropped, not refused: the call would return the whole catalog
  jq -e --arg from "$FROM" '.filter.tax_class == $from' <<<"$page" >/dev/null \
    || { echo "The tax_class filter was not applied. Stopping." >&2; exit 1; }

  total=$(jq -e '.page.total' <<<"$page")

  # products list also returns soft-deleted rows
  jq -c --arg to "$TO" '.items[] | select(.deleted_at == null)
    | {id, sku, before: .tax_class, after: $to}' <<<"$page" >> "$plan"
  skipped=$((skipped + $(jq '[.items[] | select(.deleted_at != null)] | length' <<<"$page")))

  offset=$((offset + 200))
done

echo "$plan: $(wc -l < "$plan" | tr -d ' ') planned, $total matched, $skipped soft-deleted left out"

Four details keep it safe:

  • --order id gives the pages a fixed order. Without one, the order between calls is not guaranteed, so rows could repeat or go missing between pages. This is the safe assumption, not something this guide measured.
  • The .filter check. The API drops a filter it does not know instead of refusing it, so a filter that never reaches it (a misspelled name, or a flag your CLI version does not have) would list every product. The response echoes the filters it applied in filter, and the script refuses to continue unless yours is there.
  • The deleted_at check. products list includes soft-deleted products, and the total counts them. The script leaves them out and prints how many, which is why the number planned can be lower than the number matched.
  • A new file every run. The name carries the tenant and the time, so a plan for staging is easy to tell from one for production, and a second run never overwrites the first. The tenant in the name is REVENEXX_TENANT, the same value every call passes with --tenant, so the name and the target agree. Keep the file: it holds the old value of every row.

The plan has one line per product, with the value before and after:

plan-acme-20261006-091500.jsonl
{"id":"0f6c…","sku":"ACME-4711","before":"standard","after":"reduced"}

2. Review the plan

Nothing has changed yet. Read the plan before you apply it:

Review
P=plan-acme-20261006-091500.jsonl
wc -l < "$P"                              # rows planned
jq -r .before "$P" | sort | uniq -c       # one value, as expected
sort -R "$P" | head -5                    # a sample to read

The count should equal the "planned" number the script printed, which is "matched" minus "soft-deleted left out". If products changed while you were planning, plan again into a new file.

Optionally, rehearse the apply step on a few rows first, once you have apply.sh from step 3: cut the plan down and apply that with head -n 5 "$P" > try.jsonl && ./apply.sh try.jsonl. Then read those five products back where you normally look at tax class (for example in Cockpit, if your tenant shows it there). The rehearsal keeps its own try.jsonl.done, so the full apply writes those five rows again, which does no harm.

3. Apply: one row at a time, recording each success

products update is a partial update: it changes only the fields in the body and leaves every other field alone. The id is the --id flag, and the body comes from --data.

apply.sh
#!/usr/bin/env bash
# usage: ./apply.sh plan-acme-20261006-091500.jsonl
set -uo pipefail

plan=$1
done_file="$plan.done"
failed_file="$plan.failed"
touch "$done_file"
: > "$failed_file"

rows=$(wc -l < "$plan" | tr -d ' ')
if [ "${CONFIRM:-}" != yes ]; then
  read -r -p "Write $rows rows to tenant ${REVENEXX_TENANT:?set REVENEXX_TENANT}? [y/N] " answer
  [ "$answer" = y ] || exit 1
fi

streak=0
while IFS= read -r row <&3; do
  id=$(jq -r .id <<<"$row")
  grep -qxF "$id" "$done_file" && continue

  body=$(jq -c '{tax_class: .after}' <<<"$row")
  waits=0
  while :; do
    err=$(revenexx --quiet products update --tenant "$REVENEXX_TENANT" \
      --id "$id" --data "$body" 2>&1 >/dev/null </dev/null)
    rc=$?
    # The CLI already retried a 429 itself. Exit 8 means it gave up: wait longer.
    if [ "$rc" -eq 8 ] && [ "$waits" -lt 3 ]; then
      waits=$((waits + 1)); sleep 60; continue
    fi
    break
  done

  case $rc in
    0)   echo "$id" >> "$done_file"; streak=0 ;;
    2|4) echo "$err" >&2; echo "Stopping: exit $rc is not about this product." >&2; exit "$rc" ;;
    *)   jq -c --argjson rc "$rc" --arg err "$err" '. + {exit: $rc, error: $err}' <<<"$row" >> "$failed_file"
         streak=$((streak + 1))
         if [ "$streak" -ge 5 ]; then
           echo "Stopping: five failures in a row. Last error: $err" >&2; exit 1
         fi ;;
  esac

  sleep 0.2   # caps the run at 5 requests per second, usually fewer
done 3< "$plan"

failed=$(wc -l < "$failed_file" | tr -d ' ')
echo "done: $(wc -l < "$done_file" | tr -d ' ')  failed: $failed"
[ "$failed" -eq 0 ]

The script asks you to confirm the row count and the tenant before it writes (set CONFIRM=yes in a pipeline). --quiet turns on the exit codes and a structured error on stderr without changing what is printed. The script reacts to them like this:

ExitMeaningWhat the script does
0WrittenRecords the id in <plan>.done
8Rate limited (429)Waits 60 seconds and tries the same row again, up to three times, then treats it as failed
2, 4Wrong flags, or the key was refusedStops the whole run. Every remaining row would fail the same way
5, 1, anything elseThis row failed (not found, a rejected request, a server error)Writes the row, its exit code and the error to <plan>.failed, and carries on

Five failures in a row also stop the run. That catches the problem that is not about a row: a wrong tenant, a missing CLI, a limit that is not clearing. A rate limit that stays on counts as failures: after its three waits a row goes to <plan>.failed, and five such rows in a row stop the run after roughly fifteen minutes of waiting. Fix the cause and run the same command again.

Two behaviours of the CLI matter here:

  • It retries a 429 itself for the commands that are safe to repeat, including products update, and honours the Retry-After header (up to 60 seconds). Exit 8 therefore means the retries ran out, which is why the script waits a full minute instead of hammering.
  • It never retries a POST. If you adapt this script to a create command, a 429 arrives as exit 8 on the first try, and a retry is yours to make.

The pause of 0.2 seconds, plus the time each call takes, keeps the run at no more than five requests per second, so 3,000 rows take on the order of ten to fifteen minutes (an estimate, not a measurement). The tenant's limit is shared by every consumer, so a bulk job should take a small, fixed share of it and leave the rest to your storefront. See Size a launch for the budget. Do not run two applies against the same tenant at once.

The script exits non-zero when any row failed, so a pipeline can tell a partial result from a complete one.

4. Resume and retry

Run the same command again:

Resume
P=plan-acme-20261006-091500.jsonl   # the plan you applied
./apply.sh "$P"

Rows already in <plan>.done are skipped, so it picks up after the last success. The same run retries the rows that failed, because they are not in the done file, and <plan>.failed then lists only what failed this time. Writing the same value twice leaves the tax class the same. This guide has not checked whether a write with an unchanged value still bumps updated_at or fires an update event or webhook, so assume it might, and warn anything downstream that reacts to product updates.

Read the failures before you retry them blindly. The error field in each failed row says why. A 5 means the product was not found, for example because it was deleted since the plan. That will not fix itself. A 1 with a 400 means the API rejected the request body, and needs a decision, not another attempt. This script sends only the tax class, so it cannot cause a 409 conflict (the products API raises that for a duplicate SKU).

If the plan is old, plan again into a new file. Products you have already changed no longer match the filter, so the new plan holds only what is left. The first plan stays as the record of the original values.

5. Verify

Run plan.sh once more. It writes a new plan file each time, so delete the empty one afterwards. A finished change plans zero rows, because nothing matches the old tax class any more:

Verify
./plan.sh    # expect: 0 planned

Anything it still lists is a product that failed, or one that was created after your first plan. Because the script applies whatever is on disk and does not compare against the current value, a product someone else changed in the meantime is overwritten by the plan. For a change window with other editors, tell them first.

6. Undo

The plan recorded the old value of every row. Build the undo from the rows you actually applied, and apply it as its own plan:

Undo
P=plan-acme-20261006-091500.jsonl   # the plan you applied
jq -c --rawfile d "$P.done" '($d | split("\n")) as $ids
  | select(.id as $i | $ids | index($i))
  | {id, sku, before: .after, after: .before}' "$P" > "undo-$P"
./apply.sh "undo-$P"

The undo keeps its own done and failed files, because they are named after its file. It writes the old value back whatever the product holds now, so it also overwrites an edit someone made after your change.

Before you run it in production

  • Know what reads the field. A tax class decides the VAT rate that prices resolve. Check what else in your setup depends on the field you are changing (an ERP export, a price cache, an open cart) before you write 3,000 rows.
  • Keep the record. The plan, the done file and the failed file are your audit trail for the change. Store them with the ticket that asked for it. They name the tenant and carry timestamps in the file name, but they do not say who ran the script. Add that to the ticket.
  • Treat the files as data. They hold SKUs and ids. Keep them out of Git.
  • Tell the people who own the catalog the time of the run, and use the narrow key from Before you start.

Checklist

  • Planned into a file named for the tenant, with the filter checked and soft-deleted rows left out.
  • Reviewed the count and read a sample (optionally rehearsed five rows).
  • Run against staging before production.
  • Applied from the file, with .done and .failed next to it.
  • Verified with a second plan that lists nothing.
  • Kept the plan, so the change can be undone.

Where to go next

Was this page helpful?