# Zoho Desk

> Send Zoho Desk tickets and their threads to Dimension Labs with the universal API.

This brings in each Zoho Desk ticket as one conversation: its threads, the customer's messages and your agents' replies, across email, chat, social and forum channels. Private threads and ticket comments (notes) stay out. You run it on your side: a small script pulls from the Zoho Desk API and posts each thread to the [universal API](/api/tracker).

## What you need

- A Zoho Desk user who can see the tickets you want, to create the OAuth client and approve it.
- Your Zoho data centre, which sets both the accounts server and the Desk API address (see below).
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

| Data centre | Accounts server | Desk API |
|---|---|---|
| United States | `https://accounts.zoho.com` | `https://desk.zoho.com` |
| European Union | `https://accounts.zoho.eu` | `https://desk.zoho.eu` |
| India | `https://accounts.zoho.in` | `https://desk.zoho.in` |
| Australia | `https://accounts.zoho.com.au` | `https://desk.zoho.com.au` |
| Japan | `https://accounts.zoho.jp` | `https://desk.zoho.jp` |
| Canada | `https://accounts.zohocloud.ca` | `https://desk.zohocloud.ca` |
| Saudi Arabia | `https://accounts.zoho.sa` | `https://desk.zoho.sa` |
| Singapore | `https://accounts.zoho.sg` | `https://desk.zoho.sg` |
| United Arab Emirates | `https://accounts.zoho.ae` | `https://desk.zoho.ae` |

Zoho lists the accounts servers at [accounts.zoho.com/oauth/serverinfo](https://accounts.zoho.com/oauth/serverinfo) and the Desk addresses under "API Endpoints by Data Center" in the [Zoho Desk API documentation](https://desk.zoho.com/DeskAPIDocument). Zoho Desk also runs in China (`https://desk.zoho.com.cn`).

## 1. Get API access in Zoho Desk

Use a Zoho **Self Client**, which is meant for backend scripts that read your own account's data ([Self Client](https://www.zoho.com/accounts/protocol/oauth/self-client/overview.html)).

1. Go to the Zoho API console, click **GET STARTED**, hover over **Self Client** and click **CREATE NOW**. Click **CREATE**, then **OK**.
2. Copy the **Client ID** and **Client Secret** from the **Client Secret** tab.
3. In the **Generate Code** tab, enter the scope `Desk.tickets.READ`, pick an expiry time for the code (3 minutes by default), add a description and click **CREATE**. If asked, choose Zoho Desk and the portal. Copy the code.
4. Before the code expires, exchange it for a refresh token: `POST {accounts-server}/oauth/v2/token?client_id=...&client_secret=...&grant_type=authorization_code&code=...`. Store the `refresh_token`: it doesn't expire ([authorization code flow](https://www.zoho.com/accounts/protocol/oauth/self-client/authorization-code-flow.html)).
5. The script gets a new access token from the refresh token on each run (`grant_type=refresh_token`). Access tokens last one hour.

Send the access token as `Authorization: Zoho-oauthtoken YOUR_TOKEN`. The token is bound to one Zoho Desk organization, so the `orgId` header is optional; if you send it, it must match, or the call fails with `OAUTH_ORG_MISMATCH`. Zoho tells you to use the `api_domain` from the token response; the script reads the Desk address for your data centre from `ZOHO_DESK_URL` instead.

## 2. Pull new tickets

The ticket list has no "updated since" filter, so the code sorts tickets by the time of the customer's latest message, newest first, and reads back over a lookback window:

```bash
curl -H "Authorization: Zoho-oauthtoken $ZOHO_TOKEN" \
  "https://desk.zoho.com/api/v1/tickets?sortBy=-customerResponseTime&from=0&limit=100"
```

- `limit` goes from 1 to 100; page with `from`. `sortBy` accepts `responseDueDate`, `customerResponseTime` or `createdTime`, and a leading `-` sorts newest first.
- To read specific departments, pass `departmentIds`. Zoho notes that for the "All Departments" view you specify the `departmentIds`.
- Each ticket carries `id`, `ticketNumber`, `contactId`, `accountId`, `channel`, `status`, `priority`, `departmentId` and `customerResponseTime`.

For each ticket, [list its threads](https://desk.zoho.com/DeskAPIDocument): `GET /api/v1/tickets/{ticket_id}/threads?from=0&limit=200&sortBy=sendDateTime` (`limit` up to 200, oldest first with `sendDateTime`). The list returns a summary of each thread. Get the full text with `GET /api/v1/tickets/{ticket_id}/threads/{thread_id}?include=plainText`, which adds a `plainText` field. Each thread has `direction` (`in` or `out`), `author.type` (`END_USER` or `AGENT`), `visibility`, `status` (`SUCCESS`, `PENDING`, `FAILED` or `DRAFT`), `channel` and `createdTime`.

Rate limits, per Zoho: API calls spend credits from a daily pool that depends on your edition and number of users, and resets every 24 hours. Listing tickets or threads costs 3 credits a call, more for records deep in a list; getting one thread costs 1. Concurrent calls are capped by edition. When the daily credits run out, Zoho returns `429` with a `Retry-After` header in seconds. Each response's `X-Rate-Limit-Remaining-v3` header shows the credits left for the day. Your current usage is under **Setup > Developer Space > APIs**.

## 3. Map the fields

| Dimension Labs | Zoho Desk | Notes |
|---|---|---|
| `text` | thread `plainText` | Skip threads where `visibility` is `private`, and any whose `status` isn't `SUCCESS` (drafts, failed sends). Ticket comments are notes and aren't read. |
| `userId` | ticket `contactId` | Zoho's internal contact id, on every thread of the ticket. Fall back to the `sessionId`. |
| `incoming` | thread `direction` | `true` when it is `in`, `false` when `out`. |
| `sessionId` | ticket `id` | Prefix it, for example `zoho-desk-1892000000042034`. |
| `conversationId` | ticket `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | thread `createdTime` | Convert with `toMillis`. |
| `platformJson` | ticket `ticketNumber`, `channel`, `status`, `priority`, `departmentId`, `accountId`, `language`; thread `channel` | `accountId` lets you join to account data. |

**Personal data.** Don't send the ticket's `email` or `phone`, the contact's name, or the thread's `fromEmailAddress`. Threads often hold names, emails and phone numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// zoho-desk.mjs. Environment: ZOHO_ACCOUNTS_URL (for example https://accounts.zoho.eu), ZOHO_DESK_URL (for example https://desk.zoho.eu),
// ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, DL_API_KEY.
// Optional: ZOHO_ORG_ID, ZOHO_DEPARTMENT_IDS (comma-separated), ZOHO_LOOKBACK_DAYS (default 7).


const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const DESK = process.env.ZOHO_DESK_URL.replace(/\/$/, '');
const LOOKBACK = Number(process.env.ZOHO_LOOKBACK_DAYS || 7) * DAY;
const departments = process.env.ZOHO_DEPARTMENT_IDS ? `&departmentIds=${process.env.ZOHO_DEPARTMENT_IDS}` : '';
let token;

async function newToken() {
  const params = new URLSearchParams({
    client_id: process.env.ZOHO_CLIENT_ID, client_secret: process.env.ZOHO_CLIENT_SECRET,
    grant_type: 'refresh_token', refresh_token: process.env.ZOHO_REFRESH_TOKEN,
  });
  const res = await fetch(`${process.env.ZOHO_ACCOUNTS_URL}/oauth/v2/token?${params}`, { method: 'POST' });
  const body = await res.json();
  if (!res.ok || !body.access_token) throw new Error(`Zoho token request failed: ${JSON.stringify(body)}`);
  token = body.access_token;
}

// Gets a fresh token on 401, and retries rate limits (429) and server errors (5xx), honoring Retry-After.
// Returns null for 204 No Content (an empty list).
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    if (!token) await newToken();
    const headers = { Authorization: `Zoho-oauthtoken ${token}` };
    if (process.env.ZOHO_ORG_ID) headers.orgId = process.env.ZOHO_ORG_ID;
    const res = await fetch(url, { headers });
    if (res.status === 204) return null;
    if (res.ok) return res.json();
    if (res.status === 401 && attempt === 0) { token = null; continue; }
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Zoho Desk returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Sends every public thread written at or after `since` (Unix ms). Returns the new `since`.
export async function syncZohoDesk({ since, historical = false }) {
  token = null; // a new token for each run
  const cutoff = since - LOOKBACK; // tickets whose customer last wrote before this are not re-read
  let newest = since;
  for (let from = 0, done = false; !done; from += 100) {
    const tickets = (await get(`${DESK}/api/v1/tickets?sortBy=-customerResponseTime&from=${from}&limit=100${departments}`))?.data ?? [];
    if (tickets.length < 100) done = true;
    for (const t of tickets) {
      if (t.customerResponseTime && toMillis(t.customerResponseTime) < cutoff) { done = true; break; }
      for (let tf = 0; ; tf += 200) {
        const threads = (await get(`${DESK}/api/v1/tickets/${t.id}/threads?from=${tf}&limit=200&sortBy=sendDateTime`))?.data ?? [];
        for (const th of threads) {
          const ts = toMillis(th.createdTime);
          if (ts < since || th.visibility === 'private' || th.status !== 'SUCCESS') continue;
          const full = await get(`${DESK}/api/v1/tickets/${t.id}/threads/${th.id}?include=plainText`);
          const text = full?.plainText?.trim();
          if (!text) continue;
          await sendToDimensionLabs({
            text,
            userId: t.contactId ? String(t.contactId) : `zoho-desk-${t.id}`,
            incoming: th.direction === 'in',
            sessionId: `zoho-desk-${t.id}`,
            conversationId: `zoho-desk-${t.id}`,
            dimensionlabs_timestamp: ts,
            platformJson: {
              ticketNumber: t.ticketNumber, channel: th.channel, ticket_channel: t.channel, status: t.status, priority: t.priority,
              departmentId: t.departmentId, accountId: t.accountId, language: t.language,
            },
          }, { historical: historical || ts < Date.now() - HOUR });
          newest = Math.max(newest, ts);
        }
        if (threads.length < 200) break;
      }
    }
  }
  return newest;
}
```

An agent reply is picked up as long as the customer wrote on that ticket within the lookback window (`ZOHO_LOOKBACK_DAYS`, 7 days by default). A reply on a ticket where the customer has been quiet for longer is missed by the scheduled job: raise the lookback, or re-run a history load now and then. Re-sent threads are removed automatically.

## 5. Run it

Save the code as `zoho-desk.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncZohoDesk as sync } from './zoho-desk.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every public thread since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new threads to `/track`, and they are enriched automatically every day. A thread that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

Each run lists the threads of every ticket in the lookback window (3 credits each), plus 1 credit per new thread. Check that against your daily credits, and run less often or shorten the lookback if it uses too many; when the credits run out, the code waits for Zoho's `Retry-After`. Archived tickets are listed separately (`GET /api/v1/tickets/archivedTickets`) and aren't read here. You can also [upload a file](/data/upload-a-file) with one row per thread (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Zoho Desk reference

- [Zoho Desk API documentation](https://desk.zoho.com/DeskAPIDocument): tickets, threads, API credits, scopes and data centres
- [Self Client overview](https://www.zoho.com/accounts/protocol/oauth/self-client/overview.html)
- [Self Client: authorization code flow](https://www.zoho.com/accounts/protocol/oauth/self-client/authorization-code-flow.html)
- [Zoho accounts servers by data centre](https://accounts.zoho.com/oauth/serverinfo)

Checked against Zoho Desk's documentation on 2026-10-26.
