### Zoho Writer — merge keys are field *ids*, not the labels you see in the document

Three things cause most failures here: the merge key is the field's `id` and the document shows its `display_name`, the merge endpoints are **form-shaped** (a bag of top-level parameters, not one nested JSON object), and `document search` sends the wrong query parameter so it always 400s.

#### No context id — a document id is enough

Writer needs nothing in `zone ctx`. Every call is addressed by an opaque document id such as `bopca19d2061646f64d0aa3c91cd5682dc95d` — the same id WorkDrive uses (`permalink` on the record is `workdrive.zoho.com/file/<id>`), which is why `folder_id` / `parent_folder_id` anywhere in Writer means a **WorkDrive folder**, not a Writer one.

```bash
zone writer document list --limit 100 --toon
zone writer document list --resource-type merge     # merge|sign|fillable
zone writer document list --category trashed        # all|favourite|trashed
zone writer document get <id>
```

The list envelope is `{offset, limit, total_count, documents:[…]}`. `--offset` is a plain **0-based row offset** (`--limit 2 --offset 2` returns rows 3–4), and `--limit` **caps at 100** — 101 is rejected with `HTTP 400: The limit you've given exceeds the maximum allowed limit` rather than silently clamped, so a loop asking for more fails loudly. Each record carries `status`, `"active"` or `"trashed"`.

#### `document search` is wired to the wrong parameter

`zone writer document search --q proposal` sends `search=` and Zoho wants `query=`, so every call returns:

```
HTTP 400: You seem to be missing a mandatory parameter … parameter_name: "query"
```

Until the spec is fixed, search through the proxy:

```bash
zone api writer GET /documents/search --query query=proposal --query limit=10
```

#### Mail merge: read the fields before you build the data

This is the part that silently produces blank documents. Ask the document what its fields are:

```bash
zone writer document fields <id>
```

It answers `{"merge":[…], "sign":{…}, "fill":[…]}`. A merge entry looks like:

```json
{ "id": "User_Classes_and_Characteristics",
  "display_name": "User Classes and Characteristics",
  "type": "richtext", "category": "created" }
```

`display_name` is what the placeholder in the document reads — download the template as text and you see `«User Classes and Characteristics»`. **`id` is the API name and the key your merge data must use.** Spaces become underscores and punctuation is dropped, so `Non-Functional Requirements` becomes `NonFunctional_Requirements`. Transcribing the guillemet labels out of the document produces a merge that succeeds with every field empty — there is no "unknown field" error. `type` is `text` or `richtext`; richtext accepts HTML.

The `sign` block is keyed by **recipient name** then by field id, and each field's `value` holds the raw token (`{{Signature:Recipient1:(props="RS,!MV,!FW,!FH")*}}`) — that is how you learn a signature template's recipient names before sending it.

#### The merge body is a set of parameters, not one JSON object

Every merge endpoint takes `output_settings` plus **exactly one** data source, each as its own parameter:

| Parameter | Carries |
|---|---|
| `merge_data` | inline JSON — `{"data":[{"<field id>":"value"}]}` |
| `merge_data_json_content` / `merge_data_csv_content` | an uploaded file, ≤ 2 MB |
| `merge_data_json_url` / `merge_data_csv_url` | a public URL, ≤ 2 MB |
| `record_id` | a CRM / Creator / Bigin record — Writer pulls the values itself |

`output_settings` is `{"format":"pdf|pdfform|docx|html|zdoc|zip","password":"…"}`. Zoho documents the request as `multipart/form-data`, so if `--data` is refused, send the same parameters as form fields:

```bash
zone writer merge doc <id> --data '{"merge_data":{"data":[{"subTitle":"Q3","Purpose":"<p>…</p>"}]},"output_settings":{"format":"pdf"}}' --out merged.pdf

zone api writer POST /documents/<id>/merge \
  --form 'merge_data={"data":[{"subTitle":"Q3"}]}' \
  --form 'output_settings={"format":"pdf"}' --out merged.pdf
```

`test_mode: true` runs a merge without spending credit points; the output is watermarked and capped at **50 merges per day per org**. Production merges consume credits, so build the payload against `document fields` first rather than iterating on live runs.

#### `merge`, `bulk` and `template` are three different things

- **`merge <group>`** works on a **document** id and merges **one** record set. The group name is the *delivery channel*: `doc` returns the file, `store-v2` saves it to WorkDrive, `email` mails it, `webhook` POSTs it, `sign` routes it into Zoho Sign, `sharetofill` returns a fillable link, `invoke` calls a custom function, `presets` reuses saved settings.
- **`bulk merge <templateId>` / `bulk sign <templateId>`** work on a **template** id and fan out over many records. They are asynchronous: the response is a job with `status:"inprogress"` plus `merge_report_url` and `merge_report_data_url` — poll those, do not assume the documents exist when the call returns. `output_settings.merge_to` is `singledoc` or `separatedoc`.
- **`template <group>`** manages Writer's separate Templates section, which is **not** the same store as documents. This org has 405 documents and **zero** templates, so `template list` returns `{"total_count":0,…}` with a "no templates found" message — an empty result there does not mean the merge template is missing, it means it lives under `document` with `resource_type: "merge"`.

Two more shape traps: `merge email` puts the recipients at the top level (`recipient_email`, `subject`, `cc_email`, `message`, `from_email` — up to 10 addresses each), not inside an `email_settings` object. And `merge store` posts to the v1 path that Zoho no longer documents; **`merge store-v2`** carries the absolute v2 URL and is the one to use, with `output_settings` requiring `doc_name` and `folder_id`.

#### Export

```bash
zone writer document download <id> --format pdf --out ./doc.pdf
zone writer document download <id> --format txt          # to stdout
```

Verified working: `pdf docx odt rtf txt html zip epub pdfform zdoc`. With no `--format` the response is **docx**. Without `--out` the bytes go to stdout — fine for `txt`/`html`, and `--json` wraps text as `{"ok":true,"format":"text","data":"…"}`. Downloading a merge template as `txt` is the quickest way to see which placeholders it actually contains.

#### Renaming, locking and the rest go through one endpoint

`document meta` is a single POST whose body selects the action, wrapped in `operations`:

```bash
zone writer document meta <id> --data '{"operations":{"name":"Proposal v2"}}'
zone writer document meta <id> --data '{"operations":{"lock":true}}'
zone writer document meta <id> --data '{"operations":{"mark_as_final":true}}'
zone writer document meta <id> --data '{"operations":{"track_changes":"enable"}}'
```

Also worth knowing: `document create` is a **multipart file upload** (`--file`), while `document create-content` is the JSON variant that takes at most one of `text`, `url` or `template_id` plus `filename`, `folder_id` and `resource_type`. `POST /documents` creates; `POST /documents/:id` — `document version` — adds a version to an existing one. `document translate` is asynchronous: it returns `{"status":"inprogress","status_url":…}` to poll.

#### What you cannot undo

| Command | Effect |
|---|---|
| `document trash` | recoverable — `document restore`, found via `document list --category trashed` |
| `document delete` | permanent, and only valid on an already-trashed document |
| `template delete` | permanent — templates have no trash |
| `merge email`, `merge sign`, `sign send`, `bulk sign` | mail leaves the org; there is no recall |
| `document publish` | makes the document publicly reachable (`unpublish` reverses it) |
| `watermark set` | replaces any existing watermark rather than erroring |

#### Errors → what to do

| What you see | Meaning | Do |
|---|---|---|
| `400 … parameter_name: "query"` on search | `--q` maps to the wrong param | `zone api writer GET /documents/search --query query=<text>` |
| `400 The limit you've given exceeds the maximum allowed limit` | `--limit` over 100 | page with `--limit 100 --offset N` |
| a merge that succeeds but every field is blank | keys were the `display_name`, not the `id` | re-key `merge_data` from `document fields` |
| `template list` empty while a merge template clearly exists | templates and documents are separate stores | `zone writer document list --resource-type merge` |
| a merge/store call 404s or behaves oddly | the v1 store path is deprecated | use `zone writer merge store-v2` |
| bulk output missing right after the call | bulk merge is asynchronous | poll `merge_report_url` / `merge_report_data_url` |
| `document delete` refused | the document is not in the trash yet | `document trash` first |
| exit 3 | not signed in — Writer is its own consent | human runs `zone login writer` |
| exit 4 | token lacks `ZohoWriter.merge.ALL` or `ZohoSign.documents.ALL` | human re-logs in that service |

> Command names, paths, required flags and HTTP verbs come from the installed specs, so they are exact. **Verified live** against a real Writer account while writing this: the list envelope and `total_count`, the 0-based `--offset`, the hard 100 `--limit` cap, the broken `--q` on `document search` (and that `query=` works through `zone api`), the `document fields` shape and the `id` vs `display_name` split, the `«display_name»` placeholders in a downloaded template, the `sign` block keyed by recipient, all ten download formats and the docx default, and that `template list` is empty while merge documents exist. **Not verified live** — no merge, upload, send or delete was run: the `merge_data` / `output_settings` / `operations` body shapes, the `merge email` top-level recipient fields, the bulk-merge job contract and the 50-per-day test-mode cap all follow Zoho's v1/v2 API documentation.
