---
name: zoho-api-console-oauth
description: The shared Zoho API Console mechanics behind every Zoho OAuth2 registration — Server-based vs Self Client vs JavaScript client types, the multi-data-centre domain model and the Multi DC toggle that lets one client ID serve every region, the per-DC client secret, the location / accounts-server parameters on the callback, redirect-URI rules, the comma-delimited scope.operation grammar, access_type=offline plus prompt=consent, the Zoho-oauthtoken header, the 20-refresh-tokens-per-user cap that silently revokes the oldest connection, and rate limits as a per-organization concept. Read this first when registering any Zoho OAuth client; the product skills (Zoho CRM, Zoho Books, Zoho Recruit) build on it and cover only what their product adds. Use directly when the task is a plain Zoho OAuth client with no particular Zoho product named.
---

# Zoho API Console — shared OAuth2 registration mechanics

Everything common to registering an OAuth 2.0 client for **any** Zoho service: the API Console, the client types,
the data-centre model, redirect URIs, the scope grammar, refresh-token behaviour, and the per-organization limits.

The product skills — Zoho CRM, Zoho Books, Zoho Recruit — assume this file and cover only what their service adds
(a scope vocabulary, an organization identifier, a module model, a credit table). **Read this first, then the
product skill.** If you are registering a plain Zoho client with no particular service in play, this file is the
whole job.

**Zoho is one console for dozens of services.** The same client ID and secret authorize CRM, Books, Recruit, Desk,
People, Mail, Inventory, Invoice, Sign, Calendar, Meeting, Payments and the rest — the *scope* prefix decides which
service the token reaches. So this base is not just the shared half of three skills: it is the whole registration for
any Zoho service a product skill does not yet exist for.

Three things here are expensive to get wrong, and none of them fail at registration time. **A customer's data centre
is not knowable from their email address** — you learn it on the callback, and a token minted in the wrong one is
simply invalid. **The client secret is per data centre by default**, so a Multi-DC client that works in the US fails
in the EU with an error that says nothing about data centres. And **refresh tokens are capped at 20 per user per
client**, with the oldest silently revoked on the 21st — which shows up months later as one customer's oldest
connection dying for no visible reason.

## Inputs to collect before you start

Ask in one batch. Never invent.

| Input | Notes |
| --- | --- |
| **Client name** | No special characters except `_` and `&`, or the console rejects it (§3) |
| **Homepage URL** | Mandatory for every client type except Self Client (§3) |
| **Redirect URIs** | Every callback host the platform serves, exactly (§5) |
| **Which Zoho account owns the client**, and its data centre | The account's own DC is where the client is registered (§4) |
| **Which data centres customers are in** | Decides whether Multi DC must be enabled — and it usually must (§4) |
| **Scope set** | Exactly what the caller requests — the product skill supplies the strings (§6) |
| **One client for all Zoho services, or one per service?** | One client can carry scopes for several services (§1) |
| **New client, or an edit to an existing one?** | A new client ID re-authorizes every customer (§1) |

## Quick Start

1. Confirm a **new client** is needed — existing connections are bound to the current client ID (§1).
2. Sign in to `https://api-console.zoho.com` with the account that should own the client (§2).
3. Create a client of type **Server-based Applications** — the only type that fits a multi-tenant connector (§3).
4. Add **every** redirect URI, exactly (§5).
5. **Enable Multi DC on the client's Settings tab** and decide whether the secret is shared or per-DC (§4).
6. Capture the client ID and the secret **for every enabled DC** (§4, §9).
7. Build the authorize URL with comma-delimited scopes, `access_type=offline` and `prompt=consent` (§6, §7).
8. On the callback, read `location` / `accounts-server` and exchange the code **at that DC**, with that DC's secret (§4).
9. Call the API at the `api_domain` returned in the token response, with the `Zoho-oauthtoken` header (§4, §8).
10. Run a real authorize → callback → refresh round trip, and if you can, one in a second DC (§11).
11. Hand the credentials over — never commit them (§12).

## Platform state (verified 2026-09-20 — re-verify before trusting)

- **The console is the Zoho API Console at `https://api-console.zoho.com`.** Product docs variously call it the
  "Zoho Developer Console" and link to the same place. Entry point is **GET STARTED**, then a client type; once you
  have at least one client, the button is **ADD CLIENT** in the top right. Client ID and secret live on the client's
  **Client Secret** tab; Multi DC lives on its **Settings** tab.
- **One client ID works across data centres — but only after Multi DC is enabled, and the secret differs per DC by
  default.** This is the single most important fact in this file; §4 sources it.
- **Zoho's published DC list is shorter than the live one.** The OAuth multi-DC page lists eight (US, EU, IN, AU, JP,
  CA, SA, UK). The live endpoint `https://accounts.zoho.com/oauth/serverinfo` returned eleven on 2026-09-20 — those
  eight plus `ae` (UAE), `sg` (Singapore) and `inec` (`accounts.zohohq.in`). China (`accounts.zoho.com.cn`) appears in
  the product docs but not on the accounts page or in `serverinfo`; it is operated separately. **Read `serverinfo` at
  run time rather than hardcoding a table** (§4).
- **Refresh tokens do not expire by time.** They are capped at **20 per user per client**, and the 21st silently
  invalidates the oldest (§7).
- **Zoho's own docs disagree with each other on three numbers** — grant-token validity, access tokens stored per
  refresh token, and whether the auth header says `Bearer` or `Zoho-oauthtoken`. §7 and §8 say which to believe.
- **There is no app review, no verification, no publishing state and no install cap.** A newly created Zoho client
  can serve production customers immediately. Nothing here waits on Zoho. Listing in the Zoho Marketplace is a
  separate product exercise and is not required to run an OAuth integration.

If the console does not look like this, stop and report what you actually see rather than clicking on.

## 1. Decide: reuse the existing client, or create a new one

A new client means a **new client ID, and every existing customer connection is bound to the old one** — every
customer re-authorizes. Reuse the existing client for: adding a redirect URI, adding or removing scopes, enabling a
further data centre, or rotating a compromised secret.

Create a **new** client only when the user explicitly wants one: a separate app for a different product, a different
owning account, or a deliberate replacement of a compromised client. Say which path you are taking before touching
anything.

**One client can serve several Zoho services.** Scope strings are namespaced by service (`ZohoCRM.…`,
`ZohoBooks.…`, `ZohoRecruit.…`), and a single authorization may carry scopes for more than one. Whether to run one
client per service or one client for all of them is a platform decision, not a console constraint — but note the
consequence: the refresh-token cap in §7 is counted **per user per client**, so collapsing many services onto one
client also collapses their share of that budget.

## 2. Account and console access

- **Console** — `https://api-console.zoho.com`. Sign in with the Zoho account that should own the client. Any Zoho
  account can register a client; there is no developer-programme application and no fee.
- **The owning account's data centre is where the client is registered.** An account created at `zoho.com` registers
  a `us` client; an account created at `zoho.eu` registers an `eu` client. This is not a setting on the client — it
  follows the account, and it is why §4 exists.
- **Register the client in the account you intend to keep.** The client belongs to that Zoho account. Prefer an
  account the organization owns rather than an individual's.

Hand control back to the user for anything only a human can do: account creation, email verification, MFA, accepting
terms, and any identity check Zoho inserts. Do not retry a blocked step in a loop.

**If this session has no browser automation** (the usual case for a CLI or cloud run), do not pretend to click. Hand
the user an exact, ordered click path with the literal values to paste — the redirect URIs from §5 and the scope
strings from the product skill, verbatim — and continue once they report back with the client ID, the DC list, and
where they stored each secret.

## 3. Choose the client type

The console offers five, and the choice is not editable afterwards in any useful sense — it determines which fields
exist and which flow the client can run.

| Type | Flow | Gives a refresh token? | Use it when |
| --- | --- | --- | --- |
| **Server-based Applications** | Authorization code | **Yes**, with `access_type=offline` | A backend exchanges the code on behalf of **many** Zoho accounts. **The type for a multi-tenant connector.** |
| Client-based Applications (JavaScript) | Implicit | **No** | Browser-only apps with no server. Needs JavaScript Domains as well as redirect URIs. |
| Mobile-based Applications | Authorization code + PKCE | Yes | Native apps on phones and tablets |
| Non-browser Applications | Device flow | Yes | Smart TVs, printers, limited-input devices |
| **Self Client** | Authorization code from the console, or client credentials | Yes (authorization-code variant only) | **One** Zoho account, owned by the same people as the app. No redirect URI, no web UI, no end user. |

**A multi-tenant connector needs Server-based.** Self Client is the trap worth naming, because it is the fastest path
to a working token and it does not generalize: the resource owner *is* you, the grant token is generated by hand in
the console for an organization you pick from a list, and there is no mechanism by which a customer's user authorizes
it. It is right for a single-tenant back-end job and wrong for anything a customer connects. If someone proposes a
Self Client for a product that will serve other companies, that is a design conversation, not a console setting.

**JavaScript (client-based) clients cannot refresh.** The implicit flow returns an access token valid for one hour
and no refresh token, so every hour the user must be re-prompted. That is a product constraint, not a bug.

Mandatory fields per type (from Zoho's registration doc):

| Field | Server-based | Client-based (JS) | Mobile | Non-browser | Self Client |
| --- | --- | --- | --- | --- | --- |
| Client Name | Y | Y | Y | Y | Y |
| Homepage URL | Y | Y | Y | Y | N |
| Authorized Redirect URI | Y | Y | Y | N | N |
| JavaScript Domain | N | Y | N | N | N |

The client name **must not contain special characters other than `_` and `&`** — the console rejects it with "Enter
a valid client name". Homepage URL and redirect URIs must begin `http://` or `https://`.

## 4. The data-centre problem — read this twice

Zoho runs separate, non-communicating data centres, because data-residency law requires it. Each DC holds only the
data of users who registered there. **The accounts host, the token host and the API host all differ per DC**, and a
token minted in one DC is meaningless in another.

### The domains

Accounts hosts, from Zoho's OAuth multi-DC page:

| DC | Accounts host |
| --- | --- |
| United States (US) | `https://accounts.zoho.com` |
| Europe (EU) | `https://accounts.zoho.eu` |
| India (IN) | `https://accounts.zoho.in` |
| Australia (AU) | `https://accounts.zoho.com.au` |
| Japan (JP) | `https://accounts.zoho.jp` |
| Canada (CA) | `https://accounts.zohocloud.ca` |
| Saudi Arabia (SA) | `https://accounts.zoho.sa` |
| United Kingdom (UK) | `https://accounts.zoho.uk` |

Note the two that break the pattern: **Canada is `zohocloud.ca`, not `zoho.ca`**, and **China is
`accounts.zoho.com.cn`** — documented by the CRM and Recruit multi-DC pages but absent from the accounts page and
from `serverinfo`, because the China service is operated separately.

**Do not hardcode this table.** `https://accounts.zoho.com/oauth/serverinfo` returns the live map as JSON, and on
2026-09-20 it carried three locations that no doc page listed (`ae`, `sg`, `inec`). Zoho's own docs point at this
endpoint for exactly this reason.

API hosts follow a parallel but **not identical** pattern: `https://www.zohoapis.com`, `.eu`, `.in`, `.com.au`,
`.jp`, `.ca`, `.sa`, `.com.cn`. Do not derive the API host from the accounts host by string surgery — Canada's
accounts host is `zohocloud.ca` while its API host is `zohoapis.ca`. **Take the API host from the `api_domain` field
in the token response** and store it per connection. Some services publish their own product hosts instead of
`zohoapis.*` — the product skill says when.

### One client across data centres: what is actually true

> **The client ID is common to all data centres. The client secret is per data centre by default, and you may
> opt into a single shared secret. Cross-DC access works only once Multi DC is enabled on that client.**

Zoho states it three times, in three places:

- The OAuth multi-DC page: *"The **Client ID** will be common for all DCs, but the **Client Secret** can be either
  common to all the DCs or unique for each DC depending on your preference."*
- The CRM and Recruit multi-DC pages: *"The client ID remains the same, and the client secret differs from one DC to
  another. You can also choose to have the same client secret across multiple domains based on your business needs."*
- The Books OAuth page: *"If you want to use the Client ID created in one DC in other DCs, you need to enable Multi
  DC for that specific client ID from your developer console."*

So: **you do not register a separate client per data centre.** You register once, in whichever DC the owning account
lives in, and then extend that one client to the others.

**To enable it:** API Console → select the client → **Settings** tab → toggle on each required DC. Each enabled DC
then gets its **own** client secret, readable via **SHOW CODE** on that DC's row. To use one secret everywhere
instead, select **Use the same OAuth credentials for all data centers** and confirm.

**Choose deliberately, and record the choice.** A shared secret means one value to store, one rotation, and no
per-DC branching in the token exchange — simpler, and what most connectors want. Per-DC secrets mean a compromise in
one region does not reach the others, at the cost of a secret store keyed by DC and an exchange step that must look
the secret up by the callback's `location`. **A connector built assuming one secret, pointed at a client that has
per-DC secrets, fails in every DC except the one it was tested in — with `invalid_client`, which reads like a typo.**

### Discovering the customer's DC

**You cannot infer it from the email address.** A `@example.com` user may be in any DC; a `.co.uk` address says
nothing. Zoho tells you on the callback, and only then.

1. **Authorize at `https://accounts.zoho.com`.** The CRM and Recruit multi-DC pages both state: *"You must make the
   authorization request from `https://accounts.zoho.com` for the EU, AU, and IN domains. After the request is
   successful, the system automatically redirects you to your domain."* Zoho detects the user's DC from their login
   and routes them. The exception is China, which must be authorized at `https://accounts.zoho.com.cn`.
2. **Read `location` and `accounts-server` off the callback.** The redirect carries them beside the code:

   ```
   {redirect_uri}?code={grant_token}&location=in&accounts-server=https://accounts.zoho.in
   ```

   `location` is the DC key (`us`, `eu`, `in`, …); `accounts-server` is the fully-formed host. **Prefer
   `accounts-server`** — it is the value Zoho computed, and it survives new DCs being added without a code change.
3. **Exchange the code at that host**, with that DC's client secret, and store both the DC and the resulting
   `api_domain` on the connection.
4. **Refresh at the same host, forever.** The refresh call is not DC-agnostic either.

### What happens when a connector guesses

Every failure mode here reports as something other than "wrong data centre":

- **Exchanging an EU code at the US token host** → `invalid_client`. Zoho's own error table names this explicitly:
  *"There is a domain mismatch. You have registered the client and generated the grant token in a certain domain
  (US), but generating the tokens from a different domain (EU)."* It looks exactly like a mistyped secret.
- **Multi-DC enabled, but the US secret sent to the EU token host** → also `invalid_client`, from Zoho's other
  documented cause: *"Each DC holds a unique client secret."* This is the failure that passes every US test and
  breaks on the first European customer.
- **Multi DC never enabled** → the non-US authorization may not complete at all, or the exchange fails. Enabling it
  is a console toggle, not a re-registration; the client ID survives, so existing US connections are unaffected.
- **Right token, wrong API host** → authentication succeeds and API calls 401 or return nothing, because the EU API
  host has never heard of a US organization. This is the one that gets misdiagnosed as a scope problem.

**A connector that hardcodes `accounts.zoho.com` and `www.zohoapis.com` works perfectly for every US customer and
fails for everyone else.** If the code under discussion does that, say so before registering anything — the console
work is not what is broken.

## 5. Redirect URIs

On a Server-based client: **Authorized Redirect URIs**, one entry per callback host; the console's **+** adds more.
Get the list from the platform owner; for Unified.to these are one per data center:

```
https://api.unified.to/oauth/code          # us (default)
https://api-eu.unified.to/oauth/code       # eu
https://api-au.unified.to/oauth/code       # au
https://api-dev.unified.to/oauth/code      # dev
```

Note these are the **platform's** regions, not Zoho's — they are unrelated to the Zoho DC list in §4, and they do not
need to correspond. One Zoho client's redirect-URI list must contain every host your platform will send users back
to, whatever DC those users are in.

The rules, all of which surface at connect time rather than at save time:

- The `redirect_uri` sent on the authorize call and on the token call must **match a registered URI**, and must be
  **identical on both calls**. A mismatch on authorize gives `Invalid Redirect Uri`; on the token call,
  `invalid_redirect_uri`.
- Must begin `http://` or `https://`. There is no documented wildcard support — register each host literally.
- **Redirect URIs belong to the client, and are shared across every enabled data centre.** There is no per-DC
  redirect list. One list serves all of them.
- Changing the list later is a console edit, not a re-registration; existing tokens are unaffected.

## 6. Scope mechanics

The product skill supplies the scope strings. This section is how Zoho treats them.

**The grammar is `service_name.scope_name.OPERATION_TYPE`.** Three parts:

- **Service name** — the Zoho service: `ZohoCRM`, `ZohoBooks`, `ZohoRecruit`, `ZohoInventory`, `ZohoCliq`, …
- **Scope name** — the area within that service: `modules`, `settings`, `users`, `org`, `invoices`, `contacts`, …
  Some services nest one level further: `ZohoCRM.modules.leads`, `ZohoCRM.settings.fields`.
- **Operation type** — `ALL`, `READ`, `CREATE`, `UPDATE`, `DELETE`. `ALL` grants all of them.

```
ZohoCRM.modules.ALL              # every module, every operation
ZohoCRM.modules.leads.READ       # one module, read only
ZohoBooks.invoices.UPDATE        # one Books module, update only
ZohoRecruit.settings.fields.read # nesting plus an operation
```

**Scopes are comma-delimited, not space-delimited.** This is the most common mechanical error carried over from other
OAuth providers, and it fails as `Invalid OAuth scope` with no hint about the separator:

```
scope=ZohoCRM.modules.READ,ZohoCRM.settings.READ
```

Four more things worth knowing before you debug a scope error:

- **There is no scope declaration step.** Unlike consoles that require scopes to be registered before they can be
  requested, Zoho takes whatever the authorize URL sends. Nothing in the console lists your scopes. That removes a
  whole class of failure — and removes the safety net, so a typo reaches the user as an error page.
- **One bad scope fails the whole request.** `Invalid OAuth scope` means *the scope parameter* is invalid; it does
  not say which one. Bisect the list.
- **Case is inconsistent in Zoho's own docs** — `ZohoRecruit.modules.all`, `ZohoRECRUIT.modules.all` and
  `ZohoCRM.modules.ALL` all appear. Copy the casing from the product skill or from the page you are working off,
  and if a scope is rejected, try the documented alternative casing before assuming the scope does not exist.
- **Scopes are fixed at authorization.** To widen them you re-authorize; the existing refresh token does not gain
  scopes. Zoho supports **incremental authorization** — requesting more scopes later, in a second consent — for apps
  that want to ask only when a feature is used.

## 7. Refresh tokens: the cap, not the clock

**Zoho refresh tokens do not expire by time.** Every product doc says the same thing: *"A refresh token does not
expire. Use it to refresh access tokens when they expire."* There is no 90-day idle rule, no rotation, and no
reissue-on-use. A stored refresh token from two years ago still works.

What ends a refresh token is one of four events, and only one of them is yours:

1. **The cap.** *"At a time, a maximum of 20 active refresh tokens can be stored by a client per user … After the
   count reaches 20 for a user, when the client requests for an additional token, a new token will be provided and
   the oldest token will be invalidated."* Books states the consequence bluntly: this happens *"irrespective of
   whether the first refresh token is in use or not."*
2. The user revokes the app under **Connected Apps** in their Zoho account.
3. The user **changes their password** and opts to remove web sessions, or **enables MFA** and chooses to revoke
   connected apps.
4. You revoke it yourself: `POST {accounts-server}/oauth/v2/token/revoke?token={refresh_token}`.

### Why the cap is the headline

Twenty sounds generous until you count what consumes it. **Every completed consent mints a new refresh token**, and
`prompt=consent` (§ below) forces a consent every time. So: a customer who reconnects after an error, an admin
testing the integration, the same human connecting their account to your sandbox and your production, another vendor
using the same Zoho client — all draw on the same 20.

When the 21st arrives, the oldest is revoked **silently**. Nothing tells you. Your next refresh on that connection
returns `invalid_code`, months after the event that caused it, on a connection nobody touched. The customer's report
is "it just stopped working"; the cause is a reconnect somebody did in a different tab.

Practical consequences worth stating in the handoff:

- **Do not re-authorize to recover from a transient failure.** Retry the refresh; burning a consent to fix a network
  blip costs a slot and revokes someone's oldest token.
- **Revoke deliberately when you delete a connection**, so a dead token is not occupying a slot.
- **One client shared across many Zoho services multiplies the pressure** (§1).

### Getting a refresh token at all

Two parameters, both easy to omit:

- **`access_type=offline`** — *"Default value: `online`."* Without it, the token response contains an access token
  and no refresh token, and the connection dies in an hour.
- **`prompt=consent`** — `access_type=offline` returns a refresh token *only the first time* that user authorizes
  that client. Any later authorization returns an access token and silently no refresh token, because the user was
  not re-prompted. Books states the remedy exactly: *"To receive another refresh token, include `access_type=offline`
  and `prompt=consent` in your authorization request."*

**A connector that reconnects an already-connected account and stores no refresh token is almost always missing
`prompt=consent`.** And because `prompt=consent` makes every connect mint a fresh token, it is also what makes the
20-token cap a live concern. Both are correct; the tension is real; say so rather than dropping one.

### The other token limits

| Limit | Value | Source of truth |
| --- | --- | --- |
| Access token lifetime | **1 hour** (`expires_in: 3600`) | Consistent everywhere |
| Refresh tokens per user per client | **20**, oldest invalidated | Accounts token-limits page; CRM and Books agree |
| Access tokens stored per refresh token | Accounts page says **10**; CRM and Books say **15** | **Assume 10.** Reuse a valid access token until it expires and the number never matters |
| Access-token requests per refresh token | **10 per 10 minutes**, then `Access Denied` | Consistent |
| Authorization codes per user | **10 per 10 minutes**, then `access_denied` | Consistent |
| Grant-token (code) validity | Accounts and Books say **2 minutes**; CRM's validity page says **3 minutes**; CRM's and Recruit's error tables say **1 minute** | **Treat it as ~1 minute.** Exchange immediately; never queue the code |
| Self-client grant token | **3 minutes** by default, extendable in the console | Self Client only |

The throttle is the one that bites a healthy integration: a connector that fetches a fresh access token per API call
rather than caching it for the hour will hit "You have made too many requests continuously" under normal load. That
is not a rate limit on the API — it is a rate limit on token minting, and the fix is caching, not backoff.

## 8. Calling the API — the header

Product APIs want Zoho's own scheme, not `Bearer`:

```
Authorization: Zoho-oauthtoken {access_token}
```

Books is emphatic — *"(Strictly follow this format.)"* — and adds that the token **cannot** be passed as a query
parameter, only in the header. CRM and Recruit document the same form.

**Zoho's generic accounts documentation says `Authorization: Bearer {access token}`.** That is a real contradiction
in Zoho's own material, and it is the sort of thing that sends someone debugging scopes for an afternoon. **Follow
the product doc.** When a call returns an authorization error and the token is demonstrably fresh, check the header
prefix before anything else.

Call the API at the **`api_domain`** returned in the token response, not at a host you assembled yourself (§4).

## 9. Capture the credentials

At creation, the console shows the **Client ID** and **Client Secret** on the client's **Client Secret** tab. Unlike
some consoles, Zoho's secret **remains readable** there afterwards — there is no one-time reveal. That lowers the
stakes of a fumbled copy, and it raises the stakes of who has access to that Zoho account.

Capture, into a secret store:

- **Client ID** (of the form `1000.XXXXXXXX…`)
- **Client secret — one per enabled data centre**, unless the client uses a shared secret (§4). Label each with its
  DC. An unlabelled pile of secrets is unusable.
- The **client type** (Server-based) and the owning Zoho account
- The **list of enabled data centres**, and whether the secret is shared or per-DC
- **Authorize URL** `https://accounts.zoho.com/oauth/v2/auth` and **token URL** `{accounts-server}/oauth/v2/token`
  — remembering that the token URL is resolved per connection from the callback (§4)
- The exact scope strings, verbatim and comma-joined
- The redirect URIs as registered

**Rotation.** There is no documented add-then-disable path for a Zoho client secret: regenerating replaces it, and
every environment still holding the old value fails its next token exchange and refresh with `invalid_client`.
Existing **refresh tokens survive** a secret change — they are bound to the client ID, not the secret — so no
customer re-authorizes, but every refresh is broken until the new secret is deployed everywhere. Never rotate without
a cutover plan and explicit go-ahead. With per-DC secrets, rotating one DC affects only that DC.

Report a secret once so the user can paste it into their secret store, say plainly that it is now in the transcript
and can be regenerated, then move on.

## 10. Limits are a per-organization concept

Zoho's API limits are **not** per client and not per developer. They are consumed against **the customer's own
organization**, by every integration that organization uses, plus its own internal automation.

Two mechanisms, in every Zoho service that publishes limits:

- **A daily allowance**, usually in a rolling 24-hour window, scaled by the customer's edition and user count. In
  CRM and Recruit this is denominated in **credits**, where different calls cost different amounts; in Books it is a
  plain request count. Either way the customer's plan sets it, and a Free-edition customer has an order of magnitude
  less than an Enterprise one.
- **A concurrency limit** — how many calls may be in flight simultaneously — again by edition, typically 5 to 25.
  Several services add a lower **sub-concurrency** limit for expensive calls.

What this means for a connector, in the customer's words rather than yours:

- **You are sharing.** Your sync competes with the customer's other integrations and their own automation. "We are
  only making 200 calls" is not a defence if their budget is already spent.
- **Throttling is theirs to raise, not yours.** The remedy is the customer buying credits or upgrading — say so
  plainly rather than adding retries that make it worse.
- **Back off, do not re-authorize.** A throttle error is not an authorization failure, and re-running the OAuth
  flow in response burns a refresh-token slot (§7) for nothing.
- **A small customer is the binding case.** Design the sync for the Free-edition ceiling, then let bigger customers
  be comfortable.

The product skill has the actual numbers and the per-call costs.

## 11. Verify end-to-end

Authorizing with the account that owns the client proves very little — it is in the client's own DC, and it is an
admin. Test the path a customer takes:

1. Authorize from an account that is **not** the client owner, through the platform's real connect flow.
2. Confirm the token response contains a **`refresh_token`**, not just an `access_token`.
3. Confirm you captured **`location` / `accounts-server`** from the callback and **`api_domain`** from the token
   response, and that both were stored on the connection.
4. **Force a refresh**, at the stored accounts host, and make a real API call with the refreshed token.
5. Re-run the whole flow on an **already-connected** account and confirm a refresh token comes back again. That is
   the test that catches a missing `prompt=consent`.
6. **If Multi DC is enabled, authorize once from an account in a second data centre.** Nothing else proves the
   per-DC secret is wired correctly, and this is the failure that otherwise ships.
7. Confirm the API call uses the `Zoho-oauthtoken` header form (§8).

| Symptom | Cause |
| --- | --- |
| `invalid_client` on the token exchange | Wrong secret — or the code was minted in one DC and exchanged in another, or the wrong DC's secret was sent (§4) |
| Works for US customers, fails for everyone else | `accounts.zoho.com` / `zohoapis.com` hardcoded, or Multi DC not enabled (§4) |
| `invalid_code` on the exchange | Code already used, or expired — treat validity as ~1 minute (§7) |
| `invalid_code` when refreshing | Refresh token revoked: user revoked it, changed password, enabled MFA, or it was pushed out by the 20-token cap (§7) |
| No `refresh_token` in the response | Missing `access_type=offline`, or a repeat authorization without `prompt=consent` (§7) |
| One customer's oldest connection dies for no reason | The 20-refresh-tokens-per-user cap (§7) |
| `Invalid OAuth scope` | Space-delimited instead of comma-delimited, a typo, or wrong casing (§6) |
| `Invalid Redirect Uri` / `invalid_redirect_uri` | Sent URI not registered, or authorize and token calls disagree (§5) |
| Token is valid but every API call is unauthorized | `Bearer` sent instead of `Zoho-oauthtoken` (§8) |
| Token is valid, API returns nothing or 401 | Calling the wrong DC's API host — use `api_domain` (§4) |
| "You have made too many requests continuously" | Token-minting throttle: 10 access tokens per refresh token per 10 minutes. Cache the access token for its full hour (§7) |
| `429` / `TOO_MANY_REQUESTS` on API calls | The customer's organization-level credit or concurrency limit, not your client (§10) |

## 12. Hand off — never commit the secret

- **Do not** write a client secret into source control, a test, a fixture, a committed `.env`, a ticket, a PR body or
  a chat channel. Values go to the user, for a secret store or console.
- If a code change is needed (a redirect host, a scope list, DC-aware token exchange, `access_type` / `prompt`
  handling), keep it secret-free and say what the human must set out of band.
- Close with: client name, owning Zoho account and its DC; client ID; client type; **which data centres are enabled
  and whether the secret is shared or per-DC**; where each secret was delivered; the authorize endpoint and the fact
  that the token endpoint is resolved per connection; the exact scope strings; the registered redirect URIs; and
  anything left for the user to do.
- Say explicitly whether the connector **reads `location` / `accounts-server` on the callback**. If it does not,
  that is the next piece of work, and the credentials you just produced will only serve one region.

## Stop and ask

Hand back to a human rather than guessing when: customers exist outside the client's own data centre and nobody has
decided between a shared and a per-DC client secret; a client secret must be rotated on a live integration (§9);
someone proposes a Self Client for a product other companies will connect (§3); the integration needs a new client ID
and existing customers would have to re-authorize (§1); a customer is hitting organization-level credit limits and
somebody wants you to commit to a volume or a cost (§10); a Zoho service is involved that no product skill covers and
its scope vocabulary cannot be found in its own docs; or the console does not match the **Platform state** section.

## Other Zoho connectors in this family

This base covers registration for every Zoho service, not only the three with product skills. Unified.to ships
connectors for a wider set of Zoho products — among them Desk, People, Mail, Calendar, Meeting, Inventory, Invoice,
Payments and Sign — and **they share this console, this client, this DC model and this refresh-token regime.**

Two things carry over unchanged and are worth checking for any of them: the scope prefix is the service name
(`ZohoDesk.…`, `ZohoPeople.…`, `ZohoMail.…`), and **some services publish their own API hosts rather than
`zohoapis.*`** — Recruit's dedicated `recruit.zoho.*` domain is the documented case, and others follow the same
shape. Take the host from the service's own docs and reconcile it with `api_domain` before assuming either.

If the task names one of those services, work this file plus that service's own API documentation; a product skill
here is not a prerequisite.

## References

Verified to resolve on 2026-09-20.

- Zoho API Console — https://api-console.zoho.com/
- OAuth 2.0 introduction (workflow, Bearer wording) — https://www.zoho.com/developer/oauth/introduction.html
- Register your app (client types, mandatory fields) — https://www.zoho.com/developer/oauth/register-app.html
- OAuth terminology — https://www.zoho.com/accounts/protocol/oauth-terminology.html
- **Multi DC support — the client-ID/secret rule and the DC table** — https://www.zoho.com/accounts/protocol/oauth/multi-dc.html
- Live data-centre map (JSON) — https://accounts.zoho.com/oauth/serverinfo
- Data center for your Zoho account — https://help.zoho.com/portal/en/kb/accounts/manage-your-zoho-account/articles/data-center-for-zoho-account
- Server-based applications (authorization code flow, PKCE, revocation) — https://www.zoho.com/accounts/protocol/oauth/web-server-applications.html
- Get authorization code (`access_type`, `prompt`, `location`) — https://www.zoho.com/accounts/protocol/oauth/web-apps/authorization.html
- Get access token — https://www.zoho.com/accounts/protocol/oauth/web-apps/access-token.html
- Refresh access token — https://www.zoho.com/accounts/protocol/oauth/web-apps/access-token-expiry.html
- **OAuth token limits (20 refresh tokens per user per client)** — https://www.zoho.com/accounts/protocol/oauth/token-limits.html
- OAuth scope grammar — https://www.zoho.com/accounts/protocol/oauth/scope.html
- Revoke a refresh token — https://www.zoho.com/accounts/protocol/oauth/revoke-refresh-token.html
- Incremental authorization — https://www.zoho.com/accounts/protocol/oauth/incremental-authorization.html
- Self client overview — https://www.zoho.com/accounts/protocol/oauth/self-client/overview.html
