### Zoho Social — two header ids, an irreversible publish, and reads whose shape changes per network

Social is the one service in `zone` where a single successful call is **public and permanent**. Three things cause most failures: the two context ids ride as *headers* and their absence looks like an auth error; a network name is an exact enum that must also be a connected, non-expired channel; and the read endpoints return two completely different envelopes.

#### portal_id, then brand_id — both headers, both from ctx

```bash
zone social portal list                       # -> id (9 digits), plan, is_default
zone ctx social portal_id=<id>
zone social brand list                        # needs portal_id -> id (19 digits), timezone
zone ctx social brand_id=<id>
```

The order matters. `zone social brand list` before `portal_id` is in ctx does not say "missing context" — it fails with

```
HTTP 401 USER_INACTIVE_IN_PORTAL: The user has no access to the portal   (exit 5)
```

which reads like a revoked login and is not one. If a Social call 401s on a session that `zone status` calls `ok`, check ctx before asking anyone to re-login. Both ids are injected as headers, so never put `portal_id`/`brand_id` in a query string; `zone social post --brand <id>` overrides the ctx brand for one call.

#### A network is an enum, and it must be a live channel

```bash
zone social channel list       # network | id | status | name, per connected account
```

`network` values: `facebookpage`, `twitter`, `instagram`, `linkedinpage`, `linkedinprofile`, `youtube`, `pinterest`, `googlemybusiness`, `mastodon`, `threadsbyig`, `bluesky`, `tiktok`. `facebookgroup` also exists in Social's own rules but `zone social post` rejects it — publish to a group through `zone social posts publish --data`.

Each channel carries a `status`: `token_active` or `token_reconnect`. A reconnect-needed channel does not raise an error at the API edge — `zone social pinterest boards` on one returned **HTTP 200** with `{"data":[],"error":{"token_expired":true}}`, i.e. exit 0 and nothing done. Read the body, not just the exit code. Asking for a network with no channel at all is a hard **403 "User does not have permission to perform action in this channel"**.

Per-network validation rules (text limits, media counts, allowed extensions) come from the API itself:

```bash
zone social compose network-properties --type newpost --json    # --type is required; it 500s without it
```

Live text maxima: twitter 280, bluesky 300, threadsbyig 500, googlemybusiness 1500, instagram/tiktok 2200, linkedin profile+page 3000, mastodon 3000, youtube 5000, facebookpage 63206. One post going to several networks is validated per channel, so the shortest limit wins in practice.

#### Publishing: dry-run it, then get a human's yes

```bash
zone social post --message "text" --network facebookpage --network linkedinpage --dry-run
zone social posts validate --data @post.json      # validate without publishing
zone social posts publish  --data @post.json      # goes live immediately
```

`--dry-run` is global in `zone`, but it matters most here: it resolves the request and prints `{dry_run, service, method, path, body}` instead of sending, exit 0. `zone social post` also declares its own `--dry-run`, which is why the flag existed on this command before it became global — publishing is the failure you cannot walk back. Once `posts publish` returns, the post is on the networks; `zone social posts delete <postId>` removes it from Social, and nothing verified here says the audience's copy is retracted. Treat a publish as irreversible and confirm the exact text with the human first.

`zone social schedule delete <postId>`, by contrast, cancels a scheduled post cleanly *before* it fires — that one is safe.

#### The write body (read back from a live draft)

```json
{"post":{"messages":[{"message":"text",
  "channels":[{"network":"linkedinpage","brand_id":"1000000000000012345"}],
  "has_media":"none",
  "medias":[{"file_path":"<handle>"}]}],
  "schedule_time":1680056553451,"type":"schedule"}}
```

- `messages[]` is one entry per distinct copy; `channels[]` inside it lists the targets, each repeating `brand_id` as a **string**.
- Per-channel extras are allowed and are network-specific — live drafts carried `post_type:"REEL"` (facebookpage, instagram), `is_share_to_feed`, and `privacy`/`title` (youtube).
- `has_media` was `"none"` and `"video"` in live data.
- `schedule_time` is **epoch milliseconds**, as are `created_time` and `updated_time` everywhere in this API. A seconds-precision timestamp lands in 1970.
- Post ids are 19-digit numbers delivered as strings — keep them as strings.

```bash
zone social posts schedule --data @post.json     # same body + schedule_time
zone social schedule get <postId>                # full per-channel content
zone social schedule update <postId> --data '{"post":{"messages":[]}}'
zone social posts status <postId>                # per-channel {brand_id, network, status}
```

`posts status` is the only way to see whether each channel actually went out; a post can succeed on one network and fail on another.

#### Two read envelopes, and paging that is not uniform

`drafts`, `schedules` and the media library are Zoho-normalized: `{cursor, data, total_count}`, default page **10**, `cursor` is the next `--index`/`--offset` value.

```bash
zone social posts count                                        # draft/approval/scheduled/failed totals
zone social posts drafts --limit 10 --index 0
zone social schedule list --networks facebookpage,linkedinpage # --networks is required
zone social media library --library-type sociallibrary --limit 20   # or pixabay|pexels|giphy
```

`posts published` is different: it proxies each network's own API, so the rows are **network-native and not comparable across networks** (facebookpage returns `share_count`/`reactions`, linkedinpage `like_count`/`update_key`, instagram `collaborators`), `cursor` is an opaque network token with no flag to feed it back, and `--limit` is honoured by facebookpage but **ignored by instagram**, which returned 20 rows for `--limit 1`, `3` and `5` alike. Only `id`, `message`, `created_time`, `from`, `attachments` and `comment_count` are common.

Two silent-empty shapes to expect: `posts published` with no `--network`, and `approval activities` on a post with no history, both return a **200 with an empty body** (`{"ok":true,"format":"text","data":""}`) rather than an empty array; and `schedule list` with nothing scheduled returns `{"total_count":0}` with **no `data` key at all**.

#### Media: upload first, then reference the handle

```bash
zone social media upload-url --data '{"image_url":"https://example.com/a.png","file_name":"a.png"}'
zone social media upload --file ./image.png
```

Both paths yield a `file_path` handle — live values look like `ZohoSocial_7910885000000257038_1.0 NN13:-7680967717271766062`, **containing a space and a colon**. Pass it back verbatim inside `medias[]`; do not split, trim or re-encode it. `media upload` is multipart and is the one Social command that does **not** go through zone's request layer: `--dry-run` and `ZONE_POLICY` do not stop it, and a rejection surfaces as a bare `ERR_BAD_REQUEST` (exit 1) with no Zoho detail.

#### Approvals

```bash
zone social user list                                    # role, is_approver
zone social approval activities --post-id <id>
zone social approval set-status --post-id <id> --data '{"status_type":"approved","message":"ok"}'
```

`zone social posts approvals` needs an approval scope this token does not carry — it returns `SCOPE_ERROR` (exit 4), not an empty list.

#### Errors → what to do

| What you see | Meaning | Do |
|---|---|---|
| `HTTP 401 USER_INACTIVE_IN_PORTAL` | `portal_id` header missing, not a dead session | `zone social portal list`, then `zone ctx social portal_id=<id>` |
| `NO_CONTEXT` from `zone social post` | ctx has no `portal_id`/`brand_id` | set both, or pass `--brand <id>` |
| `HTTP 403 … permission … in this channel` | that network has no connected channel on this brand | `zone social channel list` and use a listed `network` |
| 200 with `"error":{"token_expired":true}` and empty `data` | channel is `token_reconnect` | a human re-connects that account in the Social UI |
| `Missing required option: --networks` / `--type` (exit 2) | Zoho requires the param; zone stops before sending | add it — `schedule list --networks`, `network-properties --type newpost` |
| `HTTP 500 INTERNAL_ERROR` (exit 7, "retryable") | `zone social posts scheduled` is a legacy endpoint that 500s on some brands | retrying will not help — use `zone social schedule list --networks …` |
| `HTTP 404 URL_RULE_NOT_CONFIGURED` | that path is not on this plan/version — `zone social report post` 404s here | drop it, or probe with `zone api social GET /<path>` |
| `SCOPE_ERROR` (exit 4) on `posts approvals` | token lacks the approval scope | human runs `zone login social` |
| empty body, `data: ""` | no rows, not a failure | add `--network`, or stop paging |
| `{"total_count":0}` with no `data` | nothing scheduled | don't dereference `data` |
| a post that published on one network only | per-channel failure | `zone social posts status <postId>` for the per-channel status |
| `ERR_BAD_REQUEST` (exit 1) on `media upload` | multipart path bypasses zone's error mapping | check file type/size against `compose network-properties` |
| exit 3 | not signed in — Social is its own consent | human runs `zone login social` |
| exit 6 | a policy rule blocked the write | intended, if `ZONE_POLICY` is read-only |

> Command names, groups, paths, required flags and HTTP verbs come from the installed specs, so they are exact. Everything described as observed — the 401-without-portal trap, channel `status`, the text limits, the network-native `published` rows, instagram ignoring `--limit`, the `cursor`/`total_count` envelope, millisecond timestamps, the `file_path` format, the empty-body shapes and the 500/404/scope failures — **was verified live against a real Zoho Social portal**, read-only plus `--dry-run`. Nothing was published, scheduled, edited or deleted. Write-body fields are read back from an existing draft; `type` values beyond `draft`, and `media upload-url`'s response, come from the specs and were not exercised.
