Zoho Books API: Bulk update thousands of records using Node.js with OAuth refresh, retries and resume support

Zoho Books API: Bulk update thousands of records using Node.js with OAuth refresh, retries and resume support

Hello everyone,

During Zoho Books implementations, one common challenge is updating a large number of existing records. The current options are usually:

  • Update records manually from the UI using Mass Update (with limited batch size).
  • Update records one by one through code using the API.

For large data corrections, migrations, or post-implementation changes, manually updating records is not practical, so I created a Node.js utility that safely performs bulk custom-field updates through the Zoho Books API.

The workflow

  1. Create a custom view containing the records that need to be updated.
  2. Export the records and extract their IDs into a text file (one ID per line).
  3. Point the script at the IDs file.
  4. Define the custom fields and new values in .env.
  5. Run the migration in three stages: probedry-runrun.

Alternatively, instead of exporting IDs manually, you can write a small search function that retrieves the records you need and generates the IDs file automatically.

What the script supports

  • Multiple custom-field updates in the same request.
  • Any Books module (recurring invoices, invoices, contacts, bills, ...) via configuration — no code changes.
  • Zero-ceremony OAuth: you provide only the Self Client grant code — the script exchanges it for a refresh token on first run, persists it locally, and mints access tokens automatically from then on. No manual token calls, no hardcoded tokens.
  • Automatic re-refresh if Zoho invalidates the access token mid-run (401 handling).
  • Request timeouts, retry with backoff for HTTP 429 and 5xx (honours Retry-After).
  • Canary update: one record is updated and verified with a follow-up GET before the batch starts — a typo in a dropdown value aborts after one record, not after thousands.
  • Progress milestones roughly every 10% of the batch, with a live ETA (the initial estimate is measured from the canary request's actual round-trip, not guessed).
  • Resume capability: every outcome is appended to results.jsonl; re-running skips records already updated successfully. A token problem or a network drop mid-run costs nothing.
  • Failed-ID export (failed_ids.txt) for targeted retry.

Configuration

Everything lives in .env. You need exactly: data center, org ID, client ID/secret, a Self Client grant code, the module, the IDs file, and the fields to set — nothing else:

ZB_DC=eu
ZB_ORG_ID=123456789

ZB_CLIENT_ID=1000.xxxxx
ZB_CLIENT_SECRET=xxxxx
ZB_AUTH_CODE=1000.xxxxx

ZB_MODULE=recurringinvoices

ZB_IDS_FILE=invoice_ids.txt
ZB_THROTTLE_MS=700

ZB_FIELDS=[{"api_name":"cf_status","value":"Approved"},{"api_name":"cf_sync_date","value":"2026-07-16"}]

ZB_AUTH_CODE is the grant code from the API console (Self Client → Generate Code, with the scopes below). It is single-use and expires within 3–10 minutes, so run the script right after generating it — the first run exchanges it for a permanent refresh token and saves it to .zb_token_store.json. Add both .env and .zb_token_store.json to .gitignore, and revoke the client when the migration is done.

Gotchas worth knowing:

  • ZB_FIELDS must stay on a single line — dotenv does not parse unquoted multi-line values.
  • ZB_DC must match the data center the client was created on. A client from api-console.zoho.eu will not authenticate against accounts.zoho.com.
  • If the exchange fails with invalid_code, the grant code expired or was already consumed — generate a fresh one and re-run immediately.

ZB_MODULE is the URL path segment (recurringinvoices, invoices, contacts, ...). Note that Books responses wrap the record in a singular root key (invoice, recurring_invoice, ...) that doesn't match the URL segment — the script auto-detects it from the first GET, so you don't have to know this.

Usage

Probe the configuration and field mapping (read-only — this also performs the one-time grant-code exchange on first run):

bash
node zoho-books-bulk-cf-update.mjs probe

Update and verify one record, then stop:

bash
node zoho-books-bulk-cf-update.mjs dry-run

Execute the complete migration:

bash
node zoho-books-bulk-cf-update.mjs run

Sample run output:

48 IDs total · 0 already ok · 48 pending
auto-detected response entity key: "invoice"
Probe 639896000003678005: custom_fields present: cf_status, cf_sync_date, ...
cf_status → customfield_id 639896000000729241 (current: "Draft")
Canary update on 639896000003678005 ...
Canary verified.
Small batch (47 records) — throttle lowered to 300 ms.
1/48 · ETA 1.1 min
5/48 · ETA 1.0 min
10/48 · ETA 0.9 min
...
48/48 Done · ok=48 fail=0

Implementation details

The update process intentionally runs sequentially instead of firing parallel requests, to respect the per-minute API limit (100 requests/min per organization), avoid failures caused by throttling, and keep the migration predictable. The default 700 ms delay keeps the rate at roughly 85 requests/min. Batches of 90 records or fewer physically cannot breach the per-minute cap, so the script speeds those up automatically.

One detail that matters across orgs: custom fields in the update payload are addressed by customfield_id where possible. The script GETs one record first, resolves each api_name to its customfield_id, and only falls back to api_name addressing if the field is not present on the sample record.

Required scopes:

ZohoBooks.invoices.READ
ZohoBooks.invoices.UPDATE

(or the equivalent scopes for the module you are updating — always least-privilege rather than ZohoBooks.fullaccess.all)

Production considerations

  • Mind the daily API cap as well as the per-minute one — it varies by Books plan. A 10,000-record migration may need to be split across days on lower plans.
  • Dropdown custom fields must receive a value that matches an option exactly (spacing, hyphens, casing — especially with non-Latin characters). The canary catches this before the batch runs.
  • An in-memory ID list is fine into the tens of thousands; the point of results.jsonl is durability — progress survives crashes, token expiry, and Ctrl+C.
  • For extremely large or recurring migrations, the same logic can move into a queue-based Node.js worker where you control execution time, retry strategy, parallelism, and monitoring.

Where this is useful

  • Updating custom fields after migrations.
  • Fixing incorrectly imported data.
  • Updating integration-generated records.
  • Applying bulk corrections after changing business logic.
  • Cleaning up data after implementation projects.

Sharing this pattern because bulk updates are a common challenge when working with the Zoho Books API. Script and example .env attached.