### Zoho CRM — payloads, field formats and workflows (read before writing)

Everything below is the V8 record API as `zone crm` sends it. Field shapes were checked against a live org, not guessed.

#### Metadata first, always

Field API names are not labels (`Last_Name`, `Deal_Name`, `Closing_Date`), picklist values must match exactly, and a wrong field name on a write is **silently dropped**, not rejected. Get the real names from the org before writing anything:

```bash
zone crm pull -o ./metadata          # modules, fields, picklists, layouts, functions -> ./metadata
cat ./metadata/.zcrm/.store/field_map.json      # { Module: { Field_API_Name: data_type } }
zone crm field list Leads --toon                # or live, one module
zone crm field picklist-values <fieldId> --module Leads
zone crm module list --toon                     # module api_names (Leads, Deals, Custom_Module__s ...)
```

#### The record envelope

Every create/update/upsert body is `{"data": [ record, ... ]}` — an array, even for one record. Responses come back the same way: `data[i].code` is `SUCCESS` or an error code, `data[i].details.id` is the record id.

```bash
zone crm record create Leads --data '{"data":[{"Last_Name":"Doe","Company":"Acme","Email":"j@acme.com"}]}'
zone crm record update Leads 4920526000011018013 --data '{"data":[{"Phone":"+971501234567"}]}'
zone crm record upsert Leads --data '{"data":[{"Email":"j@acme.com","Last_Name":"Doe"}],"duplicate_check_fields":["Email"]}'
zone crm record delete Leads 4920526000011018013
zone crm record get Leads 4920526000011018013 --fields Last_Name,Company,Owner --toon
zone crm record list Deals --fields Deal_Name,Stage,Amount --per-page 200 --page 1 --toon
```

- Up to 100 records per create/update/upsert call. Update bodies may carry several records, each with its own `id`.
- `upsert` matches on `duplicate_check_fields` (top level of the body, not inside the record); without it Zoho uses the module's unique fields. It returns `action: "insert"` or `"update"` per record.
- Optional top-level keys: `"trigger": ["workflow","approval","blueprint"]` (which automations fire; `[]` fires none), `"lar_id"` (layout assignment rule).
- `--data` accepts inline JSON, `@file.json`, or `-` for stdin.

#### How each field type is written

| Type | Write it as | Read back as |
|---|---|---|
| text / email / phone / website | `"string"` | same |
| picklist | `"Facebook"` — exact configured value | same |
| multi-select picklist | `["Zoho CRM","Zoho Books"]` | array |
| lookup (Account_Name, Contact_Name …) | `{"id":"4920526000000123456"}` | `{"name":"Acme","id":"…"}` |
| owner (`Owner`) | `{"id":"<user id>"}` — `zone crm user list` | `{"name","id","email"}` |
| date | `"2026-09-01"` (`yyyy-MM-dd`) | same |
| datetime | `"2026-09-01T14:30:00+04:00"` (ISO 8601 **with offset**) | same |
| boolean | `true` / `false` | same |
| integer / double / currency | number, not string | number |
| textarea | string, `\n` allowed | same |
| Tag | `zone crm tag add …` (not via the record body) | `[{"name","id"}]` |
| id | never in a create body; `"id":"…"` in update/upsert | `"4920526000011018013"` (a string) |

Record ids are 19-digit numbers that arrive as **strings**. Keep them as strings; do not parse to a JavaScript number (precision loss).

#### Finding records: search vs COQL

```bash
zone crm record search Contacts --criteria "(Email:equals:j@acme.com)" --fields Full_Name,Account_Name
zone crm record search Leads --email j@acme.com          # shortcuts: --email, --phone, --word
zone crm coql "select id, Deal_Name, Amount from Deals where Stage = 'Closed Won' and Amount > 10000" --toon
zone crm coql "select id from Leads" --count
zone crm coql "select id, Email from Leads" --all --out leads.json   # every row, paged by id (no 100k ceiling)
```

- Search criteria: `(Field:operator:value)`, operators `equals`, `not_equal`, `starts_with`, `in`, `between`, `greater_than`, `less_than` …; combine with `and`/`or` inside outer parentheses. Max 10 criteria.
- COQL: `WHERE` is mandatory (zone injects `id is not null` if you omit it), `limit` ≤ 2000, `OFFSET` dies at 100,000 — `--all` avoids that by paging on `id`. Up to 50 fields documented (73 pass live); `--split-fields` chunks wider selects. Aggregates need `GROUP BY`.
- `--fields` is mandatory on `record list`/`related`/`converted` in V8 — the CLI tells you if you forget.

#### Creating modules and fields — Zoho's per-call caps

```bash
zone crm module create --data '{"modules":[{"api_name":"Rentals","singular_label":"Rental","plural_label":"Rentals"}]}'   # exactly ONE module per call
zone crm field create Rentals --data @fields.json --dry-run     # 40 fields -> shown as 8 calls of 5
zone crm field create Rentals --data @fields.json               # sent in chunks of 5, results merged
zone crm record create Rentals --data @rows.json                # >100 records -> chunks of 100
```

- Zoho accepts **5 custom fields** per create/update call and **100 records** per create/upsert call, and rejects a larger body outright. `field create`, `field update-many`, `record create` and `record upsert` split an over-limit `--data` array into chunks automatically and merge the per-item results; `info.batch` says how it was split. A failed chunk stops the run — earlier chunks stay applied (no cross-call transaction), and `error.details.batch` says how many were sent so you resend only the rest.
- Per-module caps still apply: 10 / 155 / 300 / 500 custom fields on Standard / Professional / Enterprise / Ultimate, max 2 unique fields, 1 auto-number, 2 auto-refresh formula fields.
- After creating fields, read them back — `zone crm field list <Module>` — because an unknown `api_name` in a later write is dropped silently.
- Field payload traps (`field create`/`update` check these before sending): a unique field is `"unique": {"case_sensitive": false}`. `"casesensitive"` is accepted with **SUCCESS and creates no constraint**, and metadata reads back `unique: {}` either way, so prove it by writing a duplicate (`DUPLICATE_DATA`). Picklist values go in `"options"` (`pick_list_values` gets a misleading `EXPECTED_DEPENDENT_FIELD_MISSING`, so zone rewrites it). A lookup needs `lookup.display_label`. `textarea.type` is `small`, `large` or `rich_text`.
- On API v9, picklist values come back under `options` and layouts carry none. `field list`/`get` and `layout list`/`get` copy them into `pick_list_values` for you.

#### Notes, attachments, tags, related records

```bash
zone crm note add Leads <id> --title "Call" --content "Spoke to J."
zone crm attachment upload Leads <id> --file ./quote.pdf       # or --url <link> [--name <filename>]
zone crm attachment download Leads <id> <attachmentId> --out quote.pdf
zone crm tag add Deals <id> --tags "hot,q3"                    # --over-write replaces instead of appending
zone crm record related Accounts <id> Contacts --fields Full_Name,Email
zone crm record link Products <productId> Price_Books <priceBookId> --data '{"data":[{"list_price":100}]}'
```

#### Deals: stage, blueprint, conversion

```bash
zone crm blueprint get Deals <id>                                # current state + allowed transitions
zone crm blueprint update Deals <id> --data '{"blueprint":[{"transition_id":"<id>","data":{"Stage":"Negotiation"}}]}'
zone crm record update Deals <id> --data '{"data":[{"Stage":"Closed Won","Closing_Date":"2026-09-30"}]}'   # only if no blueprint governs Stage
zone crm lead conversion-options <id>                            # what the conversion will map to
# lead convert = POST /Leads/<id>/actions/convert
zone crm lead convert <id> --data '{"data":[{"overwrite":true,"notify_lead_owner":true,"Deals":{"Deal_Name":"Acme rollout","Closing_Date":"2026-12-31","Stage":"Qualification"}}]}'
```

A record under a blueprint **rejects a direct field update** for the governed field — use `blueprint update` with a `transition_id` from `blueprint get`. Lead conversion has no typed command; the `zone api` form above is the documented endpoint.

#### Mass and bulk

```bash
zone crm record mass-update Leads --data '{"data":[{"Lead_Status":"Contacted"}],"ids":["…","…"]}'   # or "cvid": "<custom view id>"
zone crm record mass-update-status Leads --job-id <jobId>

# Bulk read: > 200 rows per call, up to 200,000 per page, async
zone crm bulk read Leads --fields id,Email,Company --criteria '{"field":{"api_name":"Lead_Status"},"comparator":"equal","value":"Contacted"}'
zone crm bulk status read <jobId>
zone crm bulk download read <jobId> --out leads.zip

# Bulk write: CSV in a ZIP, then a job
zone crm bulk upload ./leads.zip                 # -> file_id
zone crm bulk write --body '{"operation":"insert","resource":[{"type":"data","module":{"api_name":"Leads"},"file_id":"<file_id>","field_mappings":[{"api_name":"Last_Name","index":0}]}]}'
zone crm bulk status write <jobId>
```

Mass update and both bulk jobs are asynchronous: the create call returns a job id, and the status call tells you when it is done. Poll; do not assume.

#### Deluge functions

```bash
zone crm fn pull my_function -o ./functions     # .ds source
zone crm fn push ./functions/my_function.ds     # save (real COMPILATION_ERROR line numbers)
zone crm fn test my_function --args '{"leadId":"123"}'    # runs the SCRIPT you have
zone crm fn invoke my_function --args '{"id":"1"}'        # runs the SAVED function over REST (must be REST-exposed)
zone crm fn rest-api my_function                          # is it exposed? (enabling is console-only)
zone crm fn create wf_sync --category Automation --arg recordId:string   # workflow rules list ONLY Automation functions
zone crm fn create nightly --category Schedule                           # schedules list ONLY Schedule functions
zone crm fn info my_function                              # api id + console dependent_id, associated, debug_mode
zone crm fn delete my_function                            # by api_name; says what blocks it
zone crm fn invoke webhook_fn --data @payload.json --headers '{"x-hub-signature-256":"sha256=…"}'
```

- `--category` is case-sensitive at Zoho (zone normalises it). A Standalone function returns `string`: a `void` Standalone fails on push, so that is the default. Automation and Schedule default to `void`. Zoho ignores `arguments` sent at create until code with that signature is pushed, so with `--arg` zone pushes a stub that compiles.
- **`fn test` cannot exercise a `crmAPIRequest` (REST/webhook) function.** The test endpoint builds `crmAPIRequest` itself with body, headers and method null, whatever you pass. Replay a real request with `fn invoke --data --headers` against the REST URL.
- `fn delete` is refused while the function is REST-exposed (turn the toggles off in the console), called by another function (`function has references`) or bound to a workflow, button or schedule (`function has associations`, see `fn info`).
- Execution logs are console-only. No API version accepts either id on `/settings/functions/<id>/logs`.

Writing the function itself: load the `zoho-deluge` skill (or `zone llm deluge`).

#### Custom buttons

```bash
zone crm custom-button list --module Leads
zone crm profile list                       # profile ids for "profiles"
zone crm layout list Leads                  # layout ids for "layouts"
zone crm features get --api-names custom_button   # edition cap (details.limits.total)
zone crm custom-button create --module Leads --data '{"custom_buttons":[{
  "name":"Search city","position":"view","action":"url",
  "profiles":[{"id":"4920526000000026011"}],"layouts":[{"id":"4920526000000091055"}],
  "details":{"url":{"url_expression":"https://www.google.com/search?q=${!Leads.City}","encode_format":"UTF-8","display_in":"new_tab"}}}]}'
zone crm custom-button update <id> --module Leads --data '{"custom_buttons":[{"description":"…"}]}'
zone crm custom-button delete <id> --module Leads
```

- **`--module` is mandatory on every verb**, bulk ones included; without it the reply is `One of the expected parameter is missing {"param_name":"module"}`.
- `position` and `action` are regex-enforced. `position`: `create_clone`, `edit`, `view`, `list_view`, `list_view_each_record`, `list_view_without_record`, `related_list`, `wizards`, the `standard_*` and `canvas_*` variants, `zia_widgets`. `action`: `url`, `custom_function`, `web_tab`, `widget`, `circuit`, `cscript`, `kiosk`, `slyteui`, `smart_prompt_builder`.
- `details` is keyed by the action. For `url`: `{"url":{"url_expression":…,"encode_format":"UTF-8","display_in":"new_tab|new_window|same_tab"}}` — `same_window` is rejected. Merge fields in the expression are `${!Leads.City}`.
- The response gives `details.id` (the new button) and `details.rid`. `update` takes only the keys you change, with the id in the path; `update-many` takes the id inside each object.
- List responses include marketplace buttons (`"source":"marketplace"`) alongside your own. `--include-inner-details details.zia_config.type` expands nested detail blocks, the way the CRM console asks for them.

#### Module customization — links, layout rules, validation rules, locking, dependencies, widgets

```bash
zone crm custom-link list --module Leads
zone crm layout-rule list --module Leads
zone crm validation-rule list --module Deals
zone crm lock get Leads
zone crm map-dependency get <layoutId> --module Deals          # parent/child pairs
zone crm map-dependency get-by-id <layoutId> <id> --module Deals  # full picklist maps
zone crm widget list && zone crm widget get <id>
```

`--module` is mandatory on **every** verb of every family here. Beyond that they disagree with each other, and the differences are the whole difficulty:

| Family | Extra param | Read-back is complete? | Bulk delete |
|---|---|---|---|
| `custom-button` | — | yes | `delete-many --ids` |
| `custom-link` | — | yes | `delete-many --ids` |
| `layout-rule` | `--layout-id` on get/create | **no — conditions never returned** | none |
| `validation-rule` | `--layout-id` on get/create, **never on delete** | **no — conditions, alert and name never returned** | none |
| `lock` | — | yes (criteria included) | `delete-many --ids` |
| `map-dependency` | layout id is positional | yes (`get-by-id` has the maps) | none |
| `widget` | — | yes | `delete-many --ids` |

- **Layout rules and validation rules are write-only in their interesting half.** List and get return name, field, layout and `active`, never the conditions, so an org's rule logic cannot be exported or diffed through the API. Keep the payload you sent in version control; it is the only record you will have.
- **Validation rules: creating the FIRST rule on a module returns 500 INTERNAL_ERROR** on every payload shape and every API version (reproduced on Leads, Contacts and Accounts, Sep 2026, while Deals — which already had rules — validated normally). Create rule #1 in the console, then the API works for the rest. A layout at its rule cap answers `VALIDATION_RULE_LIMIT_EXCEEDED`, and that cap blocks updates to existing rules too.
- **`validation-rule delete` takes `--module` only.** Adding `--layout-id`, which `get` requires, degrades the call to a bare `400 unable to process your request`.
- `validation-rule update` wants the id in the **body** as well as the path (`required field not found ["rid","id"]`).
- **`map-dependency` writes take field IDS, not api_names** (`zone crm field list <module>`); an api_name alone answers `EXPECTED_FIELD_MISSING` on `parent.id`. Every other family here accepts api_names.
- **`custom-link` create is key-strict**: an unknown key (`category`, `display_in`, `location`, …) fails with a bare `400 custom_links` naming no field. Mandatory set is `name`, `url`, `url_encoding`, and one of `profiles` / `user_types`.
- **`lock create` needs `lock_type`** — only `manual`, `automatic` or `both`. An automatic config carries `locking_rules[]` with a criteria block.
- **Widget create AND update send the payload in the `metadata` query parameter**, not a JSON body (zone does the serialising from `--data`). `widget code` downloads only Zoho-hosted widgets; an external one answers `only internal widgets can be downloaded`.

**Putting a page in the top navigation** takes three objects, in this order:

```bash
zone crm widget create --data '{"widgets":[{"name":"Ops board","type":"webtab","hosting":{"type":"external","url":"https://example.com/w.html"},"mobile_compatible":true}]}'
zone crm module web-tab-create --data '{"web_tabs":[{"name":"Ops board","type":"widget","details":{"widget":{"id":"<widget id>"}},"profiles":[{"id":"<profile id>"}]}]}'
zone crm tab-group update <groupId> --data '{"tab_groups":[{"modules":[…, {"id":"<web tab id>"}]}]}'
zone crm module web-tab-list                     # read them back
```

- A **widget** is only a registration; a **web tab** is the nav entry that shows it. `type` is `web_link | widget | slyte` and the `details` key must match: `{"web_link":{"url":…}}` (an object, never a bare string) or `{"widget":{"id":…}}`. `profiles` is mandatory.
- **A web tab's name is capped at 15 characters.** Longer fails with `maximum_length 15` — on update too.
- **There is no GET on `/settings/web_tabs`** (the collection answers `The http request method type is not a valid one`). Zoho returns web tabs inside the MODULE list, tagged `module_type: webtab_link | webtab_widget`; `module web-tab-list` is that filter.
- **`tab-group` create needs module IDS**, not api_names, and its `modules` array is the tab ORDER — send the whole list, not a delta. A PUT on the collection instead of `/:id` answers `Tab group id not found`.

**Teamspaces** are the newer layer above tab groups — a tab group binds modules to *profiles*, a teamspace binds them to a team with folders and per-folder ordering. An org that uses teamspaces drives its navigation there.

```bash
zone crm team-space list --filters '{"field":{"api_name":"apps.api_name"},"comparator":"equal","value":"Zoho_CRM"}'
zone crm team-space get <id> --include access_type,accessible_by
zone crm team-space update <id> --data '{"team_spaces":[{"folders":[{"name":"Delivery"}]}]}'
```

- The console drives these on **/crm/v10**, but the same paths answer on zone's v9 base — no `--api-version` pin needed.
- Only `team-space get` returns `folders` and `tab_folder_mappings`; the list leaves both empty.
- `tab_folder_mappings` is the **whole navigation, sent whole** — an order, not a delta. `{"folder":…,"resource":null}` is a folder's header row, `{"folder":null,"resource":…}` is a top-level tab, and `resource.id` is a **module** id (a web tab counts as a module, `generated_type: webtab_widget`). Read it with `get` first, then send the full array back with the ids you were given.
- Create asks for `name` → `other_props.icon_color` → `admin.id` → `access_type` (`public | shared`) → `tab_folder_mappings`, one error at a time.
- **Folders are managed through the parent** — `"folders":[{"name":"…"}]` on `team-space update`. The `/settings/team_spaces/:id/folders` sub-resource needs a scope zone does not request and answers `OAUTH_SCOPE_MISMATCH`.
- `--type` accepts only `admin`; anything else is `400 the value given is invalid`. Plugin-installed spaces (`source: marketplace_plugin`) are mixed into the list — filter on `apps.api_name` to see only your own.
- No bulk delete, and the default teamspace cannot be deleted.

#### Email and inventory templates

Zoho reworked this surface in 2026: the whole write half appeared, several read paths moved, and both families gained a folder resource. They are identical twins — every `email-template` command below has an `inventory-template` counterpart.

```bash
zone crm email-template folder-list
zone crm email-template create --data '{"email_templates":[{"name":"Welcome","subject":"Hi","content":"<p>Hi</p>","content_type":"html","primary_module":{"api_name":"Leads"},"category":"static","folder":{"id":"<folder id>"}}]}'
zone crm email-template update <id> --data '{"email_templates":[{"subject":"Hi there","version_description":"reworded subject"}]}'
zone crm email-template analytics <id> --from 2026-08-01T00:00:00Z --to 2026-09-21T00:00:00Z
zone crm email-template move-to-folder --data '{"move_to_folder":{"ids":["<id>"],"folder_id":"<folder id>"}}'
```

- **Create asks one field at a time**: `content` → `content_type` → `primary_module` → `category` → `folder`. `content_type` is `drag_and_drop_json | html | plain_text`; `category` is `draft | static` — **not** `custom`, which the error message does not tell you.
- **Templates are versioned, so every update needs `version_description`.** Without it: `MANDATORY_NOT_FOUND`. Old versions stay readable through `versions` / `version-get`.
- **`analytics` moved** to `/actions/analytics` (the old `/:id/analytics` is a 404) and both `--from` and `--to` are mandatory **ISO 8601 with a Z offset** — `2026-08-01T00:00:00Z`. A `+04:00` offset, epoch millis, a bare date and words like `last_7_days` are all rejected.
- **`mark_as_favorite` is gone**, replaced by `add-to-favorites`.
- Three actions take a body root that is *not* the resource name: `move_to_folder` (object), `generate_content` and `generate_pdf` (both JSON **arrays**, each entry needing `source`: `template | inline`).
- `send-test-mail` **sends real mail** and is the one command needing its own scope, `ZohoCRM.templates.email.send_test_mail.CREATE`.
- Inventory templates need an **enabled** inventory module; on an org with Quotes/Invoices switched off every `primary_module` answers `INVALID_MODULE`.

#### Zia BYOK — your own LLM behind CRM's AI

Three nested resources: an **integration** (`internal` = Zia itself, or `byok`) holds **vendors**, and a vendor holds **models**.

```bash
zone crm zia supported-ai-vendors      # openai, anthropic, google_ai, cohere … use these names verbatim
zone crm zia ai-integration-list       # the byok slot's id
zone crm zia ai-vendor-create --data @vendor.json
zone crm zia ai-model-create --data '{"ai_models":[{"name":"gpt-4o","active":true,"ai_vendor":{"id":"<vendor id>"}}]}'
```

- Vendor create chain: `name` → `active` → `ai_integration.id` → `api_key`. On a `byok` integration the key is mandatory (`api_key is required when ai_integration type is byok`).
- **The API key is write-only** — reads return it masked. Store it where you set it; you cannot get it back out of CRM.
- **Pass the key from a file or stdin** (`--data @vendor.json`, `--data -`), never inline, or it lands in shell history and in zone's audit log.
- Model create chain: `name` → `active` → `ai_vendor.id`.

#### Sandbox and developer edition — three hosts, environment-bound tokens

Zoho CRM serves three copies of the API: **production** (`www.zohoapis.<dc>`), the org's **sandbox** (`sandbox.zohoapis.<dc>`) and the **developer edition** (`developer.zohoapis.<dc>`). Paths are identical; only the host differs. zone picks the host from stored context (`env`), and **Zoho binds every OAuth token to the organization you pick on the consent screen** — Production, Sandbox or Developer. A production token sent to the sandbox host fails with `DOMAIN_TOKEN_MISMATCH` ("Environments specified in the API URL and bound with token do not match").

```
ZONE_HOME=./.zone-sandbox zone login crm   # pick the SANDBOX organization on Zoho's consent screen
                                          # → "crm: this token is bound to the SANDBOX organization … env=sandbox set"
ZONE_HOME=./.zone-sandbox zone status      # ENV column: SANDBOX
ZONE_HOME=./.zone-sandbox zone crm record list Leads
```

- **`zone login` sets `env` from the org.** The token response's `api_domain` reads `www.zohoapis.<dc>` even for a sandbox token (verified), so zone asks `GET /org` once at login: `org[0].type` is `production`, `sandbox` or `developer`, and zone stores `env` to match (plus the org's zgid and name on the grant). A sandbox token is accepted on both the `www.` and `sandbox.` hosts and answers with the sandbox org either way; a production token is refused on `sandbox.`; neither works on `developer.`. `zone ctx crm env=sandbox` (or `=production`, `=developer`) only moves the host — if the stored token is bound elsewhere it warns immediately, and `zone status` shows `SANDBOX ≠ token production`.
- **One store per environment.** A service has one session per store, so keep the sandbox login in its own `ZONE_HOME` (or its own project folder's `.zone`) next to the production one. Switching by `ctx` alone re-uses the same token and fails.
- **Ids differ.** Records, fields, layouts and users in the sandbox have their own ids; metadata mirrors production as of the sandbox's last refresh. Never paste a production id into a sandbox call or the reverse — pull metadata from each.
- Rehearse a change on the sandbox first: run the writes there, verify with fresh reads, then replay against the production store. Each call prints one stderr line naming the environment when it is not production; `--dry-run` reports `environment` and `base`; `--allow` and the audit log work the same on every host.
- Not covered by the switch: `zone crm extract` / `pull` (the metadata extractor reads the production org), and Deluge scripts that call other Zoho products.

#### Record operations added in 0.8.3

From Zoho's `crm-oas` repo; each row's notes name the source operation.

```bash
zone crm record update-many Leads --data '{"data":[{"id":"…","Lead_Status":"Contacted"}]}'   # up to 100
zone crm record delete-many Leads --ids 1,2,3
zone crm record related-count Leads <id> --data '{"get_related_records_count":[{"related_list":{"api_name":"Notes"}}]}'
zone crm email-draft list Leads <id>                 # + create/get/update/delete/update-many
zone crm record change-owner Leads <id> --data '{...}'   # change-owner-many for a bulk call
zone crm record-share list Leads <id>                # create / update / revoke
zone crm record assign-territories Leads <id> --data '{...}'   # remove-territories, *-many
zone crm record enrol-cadence Leads --data '{...}'   # unenrol-cadence
zone crm apis list                                   # the runtime __apis registry, as a command
zone crm features user-licenses
```

- **Verified live:** `related-count` (returns `{count, related_list}` per list), `email-draft list`, `record emails-sharing`, `lead conversion-options`, `features user-licenses`, `apis list`.
- **Scopes zone does not request yet** — these answer `OAUTH_SCOPE_MISMATCH` until the scope is added and you re-run `zone login crm`: record sharing (`ZohoCRM.share.<module>.*`), change owner (`ZohoCRM.change_owner.*`), lead mass convert (`ZohoCRM.mass_convert.leads.*`), `file download` (`ZohoCRM.files.READ`), and **audit-log exports** (`ZohoCRM.settings.audit_logs.*` — `settings.ALL` does not cover it). Each affected command says so in its `--help`.
- Bulk read/write, COQL and composite requests stay on their handlers (`bulk`, `coql`, `composite`).

#### Errors → what to do

A write answers **HTTP 200 even when it failed** — the outcome is per record in `data[i].code`, and `data[i].details.api_name` names the field at fault. A read matching nothing answers **HTTP 204**, printed as `{"ok":true,"data":""}` with exit 0. A non-2xx carries `{code, message, details, status}` and exits 5.

| What you see | Meaning | Do |
|---|---|---|
| 200, but `data[i].code` is not `SUCCESS` | that record failed; others in the batch may have succeeded | check every element — a 200 is not a success |
| `MANDATORY_NOT_FOUND` | a required field is missing — `details.api_name` names it | add it; required fields per layout: `zone crm layout get <module> <layoutId>` |
| `INVALID_DATA` | wrong type or format for `details.api_name` | see the field-type table above |
| `INVALID_DATA` naming a picklist field | not a configured option — the match is exact, case and spacing included | `zone crm field picklist-values <fieldId> --module <Module>`; a global picklist changes in setup, not in the payload |
| `INVALID_DATA` naming a lookup or `Owner` field | the id in `{"id":"…"}` does not exist, or a name was sent where an id belongs | resolve the id first (`zone crm record search`, `zone crm user list`); send it as a string |
| `DUPLICATE_DATA` | a unique field already holds this value — `details.api_name`, `details.duplicate_record.id` | update that record, or switch to `upsert` with `duplicate_check_fields` |
| `INVALID_MODULE` (HTTP 400, exit 5) | module api_name is wrong; case matters (`Leads`, not `leads`) | `zone crm module list` |
| `RECORD_NOT_FOUND` / `INVALID_URL_PATTERN` | bad id, or the path does not exist on this API version | verify the id; zone already retries v9→v8 |
| HTTP 204, `data: ""`, exit 0 | nothing matched — a missing id, an empty search, a COQL with no rows | not an error; stop paging — it does not mean the record exists |
| a requested field absent from every returned row | an unknown api_name is dropped silently on **reads** as well as writes | check it in `field_map.json` or `zone crm field list <Module>` |
| `NO_PERMISSION` | the OAuth user's profile cannot do this | tell the human; retrying changes nothing |
| `INVALID_TOKEN` / `AUTHENTICATION_FAILURE` (401) | zone refreshes access tokens itself, so the grant behind them is rejected — revoked, or wrong datacenter | human runs `zone login crm` |
| `OAUTH_SCOPE_MISMATCH` (exit 4) | the token never carried this scope | human re-logs in that service |
| `LIMIT_EXCEEDED` / `TOO_MANY_REQUESTS` (429, exit 7) | API credits spent, or the per-minute rate limit | back off; for volume use `zone crm bulk read` / `bulk write` |
| `DEPENDENT_FIELD_MISSING` | a field this one depends on was not sent | send both |
| `FEATURE_NOT_SUPPORTED` / `NOT_SUPPORTED` | the plan or edition lacks it | tell the human |
| exit 3 | not signed in — CRM is its own consent | human runs `zone login crm` |
| exit 6 | a policy rule blocked the write | intended, if `ZONE_POLICY` made the session read-only |
| exit 7 | transient — network, 429 or a 5xx | back off and retry the same call |

> `INVALID_MODULE`, the 204/exit-0 empty read and the silent `--fields` drop were checked live; the other codes are Zoho's documented ones.
