Hello everyone,
During Zoho Books implementations, one common challenge is updating a large number of existing records. The current options are usually:
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.
.env.probe → dry-run → run.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.
Retry-After).results.jsonl; re-running skips records already updated successfully. A token problem or a network drop mid-run costs nothing.failed_ids.txt) for targeted retry.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.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.
Probe the configuration and field mapping (read-only — this also performs the one-time grant-code exchange on first run):
node zoho-books-bulk-cf-update.mjs probeUpdate and verify one record, then stop:
node zoho-books-bulk-cf-update.mjs dry-runExecute the complete migration:
node zoho-books-bulk-cf-update.mjs runSample 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=0The 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)
results.jsonl is durability — progress survives crashes, token expiry, and Ctrl+C.Sharing this pattern because bulk updates are a common challenge when working with the Zoho Books API. Script and example .env attached.