Skip to content

02 Get your data in /Integrations

Zoho Desk

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

  1. ZD Zoho DeskTickets, chats, calls, reviews
  2. ⟳ Your jobPull new · map fields · every 15 min
  3. { } Universal APIPOST /track · /trackhistorical
  4. Dimension LabsRead in full · enriched daily

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.

  • 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.
Data centreAccounts serverDesk API
United Stateshttps://accounts.zoho.comhttps://desk.zoho.com
European Unionhttps://accounts.zoho.euhttps://desk.zoho.eu
Indiahttps://accounts.zoho.inhttps://desk.zoho.in
Australiahttps://accounts.zoho.com.auhttps://desk.zoho.com.au
Japanhttps://accounts.zoho.jphttps://desk.zoho.jp
Canadahttps://accounts.zohocloud.cahttps://desk.zohocloud.ca
Saudi Arabiahttps://accounts.zoho.sahttps://desk.zoho.sa
Singaporehttps://accounts.zoho.sghttps://desk.zoho.sg
United Arab Emirateshttps://accounts.zoho.aehttps://desk.zoho.ae

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

Use a Zoho Self Client, which is meant for backend scripts that read your own account’s data (Self Client).

  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).
  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.

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:

Terminal window
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: 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.

Dimension LabsZoho DeskNotes
textthread plainTextSkip threads where visibility is private, and any whose status isn’t SUCCESS (drafts, failed sends). Ticket comments are notes and aren’t read.
userIdticket contactIdZoho’s internal contact id, on every thread of the ticket. Fall back to the sessionId.
incomingthread directiontrue when it is in, false when out.
sessionIdticket idPrefix it, for example zoho-desk-1892000000042034.
conversationIdticket idSame value as sessionId.
dimensionlabs_timestampthread createdTimeConvert with toMillis.
platformJsonticket ticketNumber, channel, status, priority, departmentId, accountId, language; thread channelaccountId 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.

// 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).
import { sendToDimensionLabs, toMillis } from './dimensionlabs.mjs';
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.

Save the code as zoho-desk.mjs in a project set up as in Set up your project, and run it with the 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 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 with one row per thread (check it with the file checker), then run a backfill.

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