### Zoho Billing — codes not ids, and a subscription update charges a real customer immediately

Zoho Billing (ex-Subscriptions) is a Books-family API — `{"code":0,…}` envelope, `page_context` paging, a write body that *is* the record — wrapped around one model: the **subscription**. Everything downstream (invoices, payments, credit notes, unbilled charges) is *generated by* the subscription rather than authored by you. Get a subscription change wrong and a live customer is billed the wrong amount on the wrong day, with the invoice emailed before you notice.

Void vs delete vs credit note vs write-off, immutability of sent documents, number sequences and dry-running financial writes are in the cross-cutting accounting skill (`zone llm accounting`) — read it before the first write. This guide is only the subscription-specific part.

#### Org id in a header, and the scopes still say Subscriptions

```bash
zone billing org list --toon                    # organization_id — also the auth probe
zone ctx billing organization_id=<id>           # injected as X-com-zoho-subscriptions-organizationid
```

The rebrand from Subscriptions to Billing never reached the OAuth layer: the scope family is `ZohoSubscriptions.fullaccess.all` and the header is `X-com-zoho-subscriptions-organizationid`, on a `/billing/v1` base. Typed commands have no `--query` flag, so a one-off call against another org goes through the proxy: `zone api billing GET /subscriptions --header X-com-zoho-subscriptions-organizationid=<id>`.

#### The object graph, and why half of it is addressed by code

A product holds plans; a subscription joins a customer to one plan plus any addons and a coupon, and emits invoices, payments, credit notes and unbilled charges.

**Plans, addons and coupons are addressed by their human code, not by a numeric id** — the paths are `/plans/:planCode`, `/addons/:addonCode`, `/coupons/:couponCode`. Customers, subscriptions and invoices use ids. Mixing the two is the most common 404 here.

Worse, the *same group* switches: the comment leaves take an **id** while every other leaf takes a **code**.

```bash
zone billing plan get <planCode>            # code
zone billing plan list-comments <planId>    # id — same group, different key
zone billing addon get <addonCode>   |   addon list-comments <addonId>
```

`zone billing pricebook update-price-list-item <pricebookId> <itemType> <planOrAddonCode>` takes all three: an id, a literal item type, and a code.

#### Subscription lifecycle

```bash
zone billing subscription list --filter-by SubscriptionStatus.ACTIVE --toon
zone billing subscription get <id> --json                 # read the state before changing anything
zone billing subscription create --data '{…}'
zone billing subscription update <id> --data '{…}'        # PUT — this is the upgrade/downgrade path
zone billing subscription cancel <id> --cancel-at-end true
zone billing subscription reactivate <id>
zone billing subscription pause <id> --data '{…}'   |   subscription resume <id> --data '{…}'
zone billing subscription postpone-renewal <id> --data '{…}'   |   extend-billing-cycle <id> --data '{…}'
zone billing subscription charge <id> --data '{…}'        # one-off charge, billed now
zone billing subscription apply-coupon <subscriptionId> <couponCode>   |   remove-coupon-from <id>
zone billing subscription set-auto-collect-mode <id> --data '{…}'   # online (auto-charge) vs offline
```

`cancel --cancel-at-end true` ends the subscription at the term boundary; without it the cancellation is **immediate** and the customer loses access the moment the call returns. `subscription delete` erases the record — it is not a cancellation and never what "cancel this customer" means.

`set-auto-collect-mode` flips whether renewals charge a stored card by themselves: on, for a customer who never agreed to stored-card billing, charges them without consent; off silently stops collection and lets the account drift into dunning.

#### Proration: the flag that decides who pays what

An upgrade or downgrade is `subscription update` with the new `plan` / `addons`. Two body fields govern the money:

- `end_of_term` — `true` schedules the change for the next renewal; `false` (the default) applies it **now**, mid-cycle.
- `prorate` — whether Zoho credits the unused portion of the current term and charges the difference immediately.

An immediate prorated upgrade raises a charge on the spot against the stored payment method; an immediate prorated downgrade issues credit. Neither asks for confirmation. So:

```bash
zone billing subscription get <id> --json                   # current plan, term dates, next billing date
zone billing subscription update <id> --data @change.json --dry-run --json
zone billing subscription view-scheduled-changes <id>       # what is already queued for renewal
zone billing subscription delete-scheduled-changes <id>     # cancel a queued change
```

`view-scheduled-changes` is the safety net: a change booked with `end_of_term` is invisible in the subscription's current state and fires at renewal. Check it before scheduling another, or the customer gets both. Summarize the change in words — "moves Acme from Pro to Starter at renewal on 1 Nov, no charge today" — and get a yes before sending it.

States read back include `live`, `trial`, `future`, `unpaid`, `dunning`, `non_renewing`, `cancelled`, `cancelled_from_dunning`, `expired`, `trial_expired`, `paused`. `--filter-by` takes the `SubscriptionStatus.<STATE>` form (the specs name `.ACTIVE`, `.TRIAL`, `.CANCELLED` among them).

#### Dunning is configuration, not an API

There is no dunning group, no retry-schedule endpoint and no webhook group. Failed-payment retries and the eventual `cancelled_from_dunning` follow Billing-console settings; the only related read is `zone billing setting get-churn-message-preferences`. A subscription stuck in dunning is fixed by a successful payment or a human changing settings, not by a command. What you *can* do is read the state and collect:

```bash
zone billing subscription list --filter-by SubscriptionStatus.UNPAID --toon
zone billing invoice list --subscription-id <id>
zone billing invoice collect-payment <invoiceId> --data '{…}'   # charge the stored method now
zone billing event list --page 1                                # the feed behind webhooks
```

#### Invoices are generated, not authored

```bash
zone billing invoice list --customer-id <id> --filter-by <f>
zone billing invoice add-usage-charges <invoiceId> --data '{…}'   # pending invoices only
zone billing invoice delete-line-item-from <invoiceId> <itemId>   # pending invoices only
zone billing invoice convert-open <invoiceId>                     # pending -> open (issued)
zone billing invoice mark-as-void <invoiceId>
zone billing invoice write-off <invoiceId> --data '{…}'   |   apply-credits <invoiceId> --data '{…}'
zone billing unbilledcharge get <id>   |   unbilledcharge convert-invoice <id>
```

Line items can only be added or removed while the invoice is **pending**; after `convert-open` it is an issued document under the usual rules. Metered usage and mid-cycle additions accumulate as **unbilled charges** and land on the next invoice unless converted early.

The credit note group has no `list` — only `create`, `get`, `void`, `convert-open`, `apply-invoices`, `refund`, `get-refund`, `email`, `delete`. To find a customer's credit notes, read the transaction feed: `zone billing transaction list --customer-id <id>`.

#### Cards and hosted pages

`zone billing customer list-all-cards <customerId>` and `customer get-card` read stored payment methods; `subscription update-card` / `remove-card` manage them. **Do not put raw card or bank details into any of these.** Card capture goes through a hosted page the customer fills in themselves — `zone billing hostedpage new-subscription`, `hostedpage create-update-card`, `hostedpage create-add-payment-method`, `hostedpage create-invoice-payment`, or `zone billing paymentlink create`, each with `--data`. Each returns a URL to send to the customer; the card never passes through you.

#### Two vocabulary traps

Estimates are **quotes** in every leaf name: `zone billing estimate list-all-quotes`, `create-quote`, `mark-quote-as-sent`, `email-quote`. Searching for `estimate list` finds nothing.

The `reportingtag` and `task` groups (31 commands) are pinned to Zoho's **v3** API, while everything else here is v1. They work normally through typed commands — zone detects an absolute path and skips the v1 base rather than concatenating it — but the two versions differ in payload and response shape, so do not assume a v1 pattern carries over.

#### Three API versions, and more routes than the docs show

- **v1 is the documented API**, but Zoho's own spec puts **reporting tags and tasks on `billing/v3`** — zone's `reportingtag` and `task` rows call v3 directly (datacenter-safe).
- **v4 exists** and answers on 35 routes (plans, addons, coupons, customers, events, projects, tasks, estimates, chart of accounts, customer-payment refunds, journals). Nothing documents it yet.
- **Billing's router serves most of Zoho's finance platform**: an unauthenticated probe finds ~340 live routes that are neither in Billing's docs nor in zone (invoices, contacts, credit notes, journals, sales orders, item variants…), mostly on v3. A live route proves the shared router has it, **not** that a Billing organization is entitled to it — confirm with an authenticated `zone api billing …` call before relying on one.
- The legacy `subscriptions/` host still routes on v1 and v3.

#### Errors → what to do

| What you see | Meaning | Do |
|---|---|---|
| empty lists, or another company's data | org header unset or wrong | `zone billing org list`, then `zone ctx billing organization_id=<id>` |
| 404 on a plan, addon or coupon | you passed an id where a **code** belongs (or vice-versa on `list-comments`) | plans/addons/coupons are keyed by code; only the comment leaves take ids |
| "no such command" for `estimate list` / `estimate create` | the leaves are quote-suffixed | `estimate list-all-quotes`, `estimate create-quote` |
| an upgrade charged the customer immediately | `end_of_term` was absent or false, with `prorate` on | schedule with `end_of_term`; reverse with a credit note, never by editing the invoice |
| a scheduled change fired unexpectedly | a queued `end_of_term` change nobody read back | `subscription view-scheduled-changes <id>` before every change |
| cannot add a line item to an invoice | it is no longer pending | issue a credit note or a separate charge |
| subscription stuck in `dunning` / `unpaid` | retry policy is console configuration | `invoice collect-payment`, or hand it to a human — there is no dunning API |
| exit 3 / exit 4 | not signed in, or the token lacks `ZohoSubscriptions.fullaccess.all` | a human runs `zone login billing` — the scope is *not* named ZohoBilling |
| exit 7 / throttled | per-minute and per-day caps per org | back off; filter lists rather than looping subscriptions |

> Command names, groups, paths, HTTP verbs, positional arguments (the code-vs-id split and the absolute-URL `reportingtag` / `task` rows included), the `--flag` surface, the `/billing/v1` base, the `ZohoSubscriptions.*` scopes and the `X-com-zoho-subscriptions-organizationid` header come from the installed specs and zone's service registry, so they are exact. The body fields — `end_of_term`, `prorate`, plan and addon payload shapes — and the full list of subscription states follow Zoho's Billing v1 documentation and were **not** re-verified live: this account has no Billing session (`zone status` reports billing as not signed in). Run `zone login billing` and every payload and state name above can be confirmed against a real organization.
