Skip to content

Commit 525c49c

Browse files
committed
fix(zoho-desk): correct API field names, scopes, and host validation
Validation pass against Zoho's published Desk API surfaced six defects that typecheck, lint, and the existing suite all passed over, because each one fails silently against the live API rather than erroring. Wire-name mismatches (Zoho ignores unknown keys, so all three were silent): - update_ticket sent `customFields`; the ticket PATCH body names it `cf`. `customFields` exists only as a deprecated alias on other Desk resources and on the separate validate-field-updates endpoint, so updates reported success and applied nothing. - ZOHO_DESK_TICKET_PROPERTIES and ZOHO_DESK_CONTACT_PROPERTIES advertised a `customFields` output; both resources return `cf`. The declared field always resolved undefined and the real one was undeclared. - list_tickets sent `departmentId`; the query param is `departmentIds`, so the department filter was dropped and every department's tickets came back. Content handling: - deriveZohoContentText matched `contentType === 'html'`, but Zoho spells the discriminator per resource: comments use `html`, threads use the MIME form `text/html`. Every thread's `contentText` was therefore raw markup - the exact opposite of the field's purpose. Now normalized across both spellings, parameterized values, and casing, with regression tests. Scopes (least privilege): - Desk.tickets.ALL -> Desk.tickets.READ + Desk.tickets.UPDATE. No tool creates or deletes a ticket; ALL additionally granted ticket DELETE. - Dropped Desk.search.READ (no search tool exists) and Desk.webhooks.READ / .UPDATE (the provider only creates and deletes), plus their orphaned SCOPE_DESCRIPTIONS entries. Host validation - the webhook provider was the only token-carrying path not anchored to the Zoho apex allowlist, including the JWKS fetch, where an unrecognized host would have stood in as the JWT issuer: - createSubscription, deleteSubscription, and verifyAuth now route their base through a shared allowlist check. - getZohoDeskApiBase validates rather than trusting injection precedence. - The organizations route uses secureFetchWithValidation with stripAuthOnRedirect, matching the attachment route it had diverged from. Block and trigger: - The trigger's department field is renamed `triggerDepartmentIds`; sharing the `departmentIds` id let a value typed as a list_tickets filter become the webhook subscription's filter when switching modes. - `isPublic` no longer serializes onto all ten operations, matching the existing gating for `contentType`. - from/limit reject negatives and fractions instead of forwarding them. - update_ticket gains description, resolution, and classification (all already declared as outputs), and a departmentId input so a ticket can be moved. Accuracy corrections to user-facing text, all against the published parameter tables: `from` is 0-based (0-4999, default 0), not 1-based; per-endpoint limits are tickets 1-100/10, comments 1-100/50, threads 1-200/100; sortBy lists Zoho's actual allowed values; the two `include` sets genuinely differ per endpoint; status and priority accept comma-separated lists. Also: path IDs are trimmed via requireZohoDeskId so a pasted trailing space fails with a clear message instead of a %20 404; comment `commenter` and thread `status`/`isDescriptionThread`/`visibility`/`canReply` are now declared; ZOHO_CLIENT_ID/SECRET added to the oauth test env; docs page gains a MANUAL-CONTENT intro covering capabilities, the Professional-edition webhook requirement, and the US-data-center limitation. Not verified from documentation, needs a live account before merge: - the OAuth scope for the attachment content sub-path (Zoho publishes none, and there is an unanswered SCOPE_MISMATCH report against it) - 12 of the 17 offered webhook event ids (5 are confirmed); Ticket_Delete is documented but not offered - the ticket `descriptionContentType` key, and the POST /api/v1/webhooks body shape, neither of which appears in any reachable Zoho reference
1 parent e89452d commit 525c49c

20 files changed

Lines changed: 421 additions & 95 deletions

File tree

apps/docs/content/docs/en/integrations/zoho_desk.mdx

Lines changed: 87 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,31 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1010
color="#E42527"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
[Zoho Desk](https://www.zoho.com/desk/) is Zoho's customer support help desk. Support teams use it to receive tickets from email, web forms, chat, phone, and social channels, route them to the right department and agent, and track every customer conversation through to resolution.
15+
16+
With the Sim Zoho Desk integration, you can:
17+
18+
- **Read and filter tickets**: List tickets across an organization filtered by department, status, or priority, or fetch a single ticket by ID with its related contact, assignee, and department.
19+
- **Update tickets**: Change subject, status, priority, assignee, department, category, due date, and custom fields — useful for AI triage that classifies an incoming ticket and writes the result back.
20+
- **Work with conversations**: List and read ticket threads (the customer-facing email/chat exchange) and comments (internal agent notes), then add your own comment as public or private.
21+
- **Look up contacts**: Retrieve the contact behind a ticket to enrich it with data from your CRM or knowledge base.
22+
- **Download attachments**: Pull an attachment from a thread or comment into a Sim file you can pass to downstream blocks.
23+
- **Trigger on events**: Start a workflow when a ticket, comment, thread, contact, agent, task, or article changes in Zoho Desk.
24+
25+
**How it works in Sim:**
26+
Add a Zoho Desk block to your workflow, connect your Zoho account, and pick the Organization (portal) to work in — Sim loads the list for you from the connected account. Choose an operation and fill in its parameters; the block calls the Zoho Desk API and returns structured data for downstream blocks. For comment and thread bodies, Sim adds a derived plain-text `contentText` field alongside Zoho's raw HTML `content`, so an AI agent can read the message without HTML markup.
27+
28+
To trigger on Zoho Desk activity instead, use the block's trigger mode. Sim creates the webhook subscription in Zoho Desk for you and removes it automatically when the workflow is undeployed.
29+
30+
**Requirements and limitations**
31+
32+
> Zoho Desk webhooks require a Zoho Desk edition of **Professional or higher** — Free and Standard plans cannot create webhook subscriptions, so the trigger will fail to deploy on those plans.
33+
>
34+
> Sim currently supports Zoho accounts hosted in the **US data center** (`accounts.zoho.com`). Accounts in the EU, India, Australia, or Japan data centers cannot be connected yet.
35+
{/* MANUAL-CONTENT-END */}
36+
37+
1338
## Usage Instructions
1439

1540
Read and update Zoho Desk tickets, manage comments and threads, look up contacts, and download attachments. Can also trigger workflows from Zoho Desk webhook events.
@@ -20,21 +45,21 @@ Read and update Zoho Desk tickets, manage comments and threads, look up contacts
2045

2146
### `zoho_desk_list_tickets`
2247

23-
List tickets from a Zoho Desk organization with optional filters.
48+
List tickets from a Zoho Desk organization with optional filters. Returns a list projection: description, resolution, statusType and classification are only available from Get Ticket.
2449

2550
#### Input
2651

2752
| Parameter | Type | Required | Description |
2853
| --------- | ---- | -------- | ----------- |
2954
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
3055
| `orgId` | string | Yes | Zoho Desk organization ID |
31-
| `from` | number | No | Pagination start index \(1-based\) |
32-
| `limit` | number | No | Number of tickets to return \(max 100\) |
33-
| `departmentId` | string | No | Filter by department ID |
34-
| `status` | string | No | Filter by status \(e.g. Open, Closed\) |
35-
| `priority` | string | No | Filter by priority \(e.g. High\) |
36-
| `sortBy` | string | No | Sort field \(e.g. createdTime, -modifiedTime\) |
37-
| `include` | string | No | Comma-separated related data \(contacts, assignee, departments, team, products\) |
56+
| `from` | number | No | Pagination start index \(0-based, max 4999\) |
57+
| `limit` | number | No | Number of tickets to return \(1-100, default 10\) |
58+
| `departmentIds` | string | No | Filter by department ID \(comma-separated for multiple\) |
59+
| `status` | string | No | Filter by status, including custom statuses. Comma-separate to match multiple \(e.g. "Open,On Hold"\) |
60+
| `priority` | string | No | Filter by priority. Comma-separate to match multiple \(e.g. "High,Urgent"\) |
61+
| `sortBy` | string | No | Sort field: createdTime, customerResponseTime, or responseDueDate. Prefix with - for descending. |
62+
| `include` | string | No | Comma-separated related data to embed. Allowed: contacts, products, departments, team, isRead, assignee |
3863

3964
#### Output
4065

@@ -71,7 +96,7 @@ List tickets from a Zoho Desk organization with optional filters.
7196
|`isEscalated` | boolean | Whether the ticket is escalated |
7297
|`isOverDue` | boolean | Whether the ticket is overdue |
7398
|`isSpam` | boolean | Whether the ticket is marked spam |
74-
|`customFields` | json | Custom field values |
99+
|`cf` | json | Custom field values, keyed by custom field API name |
75100
| `count` | number | Number of tickets returned |
76101

77102
### `zoho_desk_get_ticket`
@@ -85,7 +110,7 @@ Retrieve a single Zoho Desk ticket by ID.
85110
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
86111
| `orgId` | string | Yes | Zoho Desk organization ID |
87112
| `ticketId` | string | Yes | Ticket ID to retrieve |
88-
| `include` | string | No | Comma-separated related data \(contacts, assignee, departments, team, products\) |
113+
| `include` | string | No | Comma-separated related data to embed. Allowed: contacts, products, assignee, departments, contract, isRead, team, skills |
89114

90115
#### Output
91116

@@ -122,7 +147,7 @@ Retrieve a single Zoho Desk ticket by ID.
122147
|`isEscalated` | boolean | Whether the ticket is escalated |
123148
|`isOverDue` | boolean | Whether the ticket is overdue |
124149
|`isSpam` | boolean | Whether the ticket is marked spam |
125-
|`customFields` | json | Custom field values |
150+
|`cf` | json | Custom field values, keyed by custom field API name |
126151

127152
### `zoho_desk_update_ticket`
128153

@@ -143,7 +168,10 @@ Update fields on an existing Zoho Desk ticket.
143168
| `category` | string | No | Ticket category |
144169
| `subCategory` | string | No | Ticket sub-category |
145170
| `dueDate` | string | No | Due date \(ISO 8601\) |
146-
| `customFields` | json | No | Custom field values as a JSON object |
171+
| `description` | string | No | Ticket description |
172+
| `resolution` | string | No | Resolution notes recorded on the ticket |
173+
| `classification` | string | No | Ticket classification: Problem, Request, Question, or Others |
174+
| `customFields` | json | No | Custom field values as a JSON object, keyed by custom field API name |
147175

148176
#### Output
149177

@@ -180,7 +208,7 @@ Update fields on an existing Zoho Desk ticket.
180208
|`isEscalated` | boolean | Whether the ticket is escalated |
181209
|`isOverDue` | boolean | Whether the ticket is overdue |
182210
|`isSpam` | boolean | Whether the ticket is marked spam |
183-
|`customFields` | json | Custom field values |
211+
|`cf` | json | Custom field values, keyed by custom field API name |
184212

185213
### `zoho_desk_list_comments`
186214

@@ -193,8 +221,8 @@ List comments on a Zoho Desk ticket.
193221
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
194222
| `orgId` | string | Yes | Zoho Desk organization ID |
195223
| `ticketId` | string | Yes | Ticket ID |
196-
| `from` | number | No | Pagination start index \(1-based\) |
197-
| `limit` | number | No | Number of comments to return \(max 100\) |
224+
| `from` | number | No | Pagination start index \(0-based\) |
225+
| `limit` | number | No | Number of comments to return \(1-100, default 50\) |
198226

199227
#### Output
200228

@@ -207,6 +235,14 @@ List comments on a Zoho Desk ticket.
207235
|`contentText` | string | Plain-text rendering of content \(HTML stripped when contentType is html\) |
208236
|`isPublic` | boolean | Whether the comment is public |
209237
|`commenterId` | string | Commenter ID |
238+
|`commenter` | object | Who wrote the comment |
239+
|`name` | string | Display name |
240+
|`firstName` | string | First name |
241+
|`lastName` | string | Last name |
242+
|`email` | string | Email address |
243+
|`type` | string | Commenter type \(AGENT/END_USER\) |
244+
|`roleName` | string | Role name |
245+
|`photoURL` | string | Avatar URL |
210246
|`commentedTime` | string | Commented timestamp |
211247
|`modifiedTime` | string | Modified timestamp |
212248
|`attachments` | array | Comment attachments |
@@ -228,7 +264,7 @@ Add a comment to a Zoho Desk ticket.
228264
| `orgId` | string | Yes | Zoho Desk organization ID |
229265
| `ticketId` | string | Yes | Ticket ID |
230266
| `content` | string | Yes | Comment content |
231-
| `contentType` | string | No | Content type: plainText or html |
267+
| `contentType` | string | No | Content type: plainText or html. Defaults to plainText so agent-written text posts literally; pass 'html' to send markup \(Zoho's own API default is html\). |
232268
| `isPublic` | boolean | No | Whether the comment is public |
233269

234270
#### Output
@@ -242,6 +278,14 @@ Add a comment to a Zoho Desk ticket.
242278
|`contentText` | string | Plain-text rendering of content \(HTML stripped when contentType is html\) |
243279
|`isPublic` | boolean | Whether the comment is public |
244280
|`commenterId` | string | Commenter ID |
281+
|`commenter` | object | Who wrote the comment |
282+
|`name` | string | Display name |
283+
|`firstName` | string | First name |
284+
|`lastName` | string | Last name |
285+
|`email` | string | Email address |
286+
|`type` | string | Commenter type \(AGENT/END_USER\) |
287+
|`roleName` | string | Role name |
288+
|`photoURL` | string | Avatar URL |
245289
|`commentedTime` | string | Commented timestamp |
246290
|`modifiedTime` | string | Modified timestamp |
247291
|`attachments` | array | Comment attachments |
@@ -252,7 +296,7 @@ Add a comment to a Zoho Desk ticket.
252296

253297
### `zoho_desk_list_threads`
254298

255-
List conversation threads on a Zoho Desk ticket.
299+
List conversation threads on a Zoho Desk ticket, newest first (Zoho sorts by sendDateTime descending by default).
256300

257301
#### Input
258302

@@ -261,8 +305,8 @@ List conversation threads on a Zoho Desk ticket.
261305
| `apiDomain` | string | No | Zoho Desk data-center REST base URL |
262306
| `orgId` | string | Yes | Zoho Desk organization ID |
263307
| `ticketId` | string | Yes | Ticket ID |
264-
| `from` | number | No | Pagination start index \(1-based\) |
265-
| `limit` | number | No | Number of threads to return \(max 100\) |
308+
| `from` | number | No | Pagination start index \(0-based\) |
309+
| `limit` | number | No | Number of threads to return \(1-200, default 100\) |
266310

267311
#### Output
268312

@@ -286,7 +330,17 @@ List conversation threads on a Zoho Desk ticket.
286330
|`bcc` | string | BCC email address |
287331
|`replyTo` | string | Reply-to email address |
288332
|`isForward` | boolean | Whether the thread is a forward |
289-
|`author` | json | Thread author |
333+
|`status` | string | Delivery status of an outgoing thread \(SUCCESS/FAILED/DRAFT\) |
334+
|`isDescriptionThread` | boolean | Whether this thread is the ticket's original description |
335+
|`visibility` | string | Thread visibility \(e.g. public\) |
336+
|`canReply` | boolean | Whether the thread can be replied to |
337+
|`author` | object | Who sent the thread |
338+
|`name` | string | Display name |
339+
|`firstName` | string | First name |
340+
|`lastName` | string | Last name |
341+
|`email` | string | Email address |
342+
|`type` | string | Author type \(AGENT/END_USER\) |
343+
|`photoURL` | string | Avatar URL |
290344
|`attachments` | array | Thread attachments |
291345
|`id` | string | Attachment ID |
292346
|`name` | string | File name |
@@ -329,7 +383,17 @@ Retrieve the full content of a single Zoho Desk ticket thread.
329383
|`bcc` | string | BCC email address |
330384
|`replyTo` | string | Reply-to email address |
331385
|`isForward` | boolean | Whether the thread is a forward |
332-
|`author` | json | Thread author |
386+
|`status` | string | Delivery status of an outgoing thread \(SUCCESS/FAILED/DRAFT\) |
387+
|`isDescriptionThread` | boolean | Whether this thread is the ticket's original description |
388+
|`visibility` | string | Thread visibility \(e.g. public\) |
389+
|`canReply` | boolean | Whether the thread can be replied to |
390+
|`author` | object | Who sent the thread |
391+
|`name` | string | Display name |
392+
|`firstName` | string | First name |
393+
|`lastName` | string | Last name |
394+
|`email` | string | Email address |
395+
|`type` | string | Author type \(AGENT/END_USER\) |
396+
|`photoURL` | string | Avatar URL |
333397
|`attachments` | array | Thread attachments |
334398
|`id` | string | Attachment ID |
335399
|`name` | string | File name |
@@ -370,7 +434,7 @@ Retrieve a Zoho Desk contact by ID.
370434
|`country` | string | Country |
371435
|`zip` | string | ZIP / postal code |
372436
|`description` | string | Description |
373-
|`customFields` | json | Custom field values |
437+
|`cf` | json | Custom field values, keyed by custom field API name |
374438

375439
### `zoho_desk_get_attachment`
376440

@@ -428,7 +492,7 @@ Trigger a workflow when a Zoho Desk event occurs (ticket, comment, thread, conta
428492
| `triggerCredentials` | string | Yes | This trigger creates and manages a webhook subscription in your Zoho Desk account. |
429493
| `orgId` | combobox | Yes | The Zoho Desk organization \(portal\) to subscribe in. |
430494
| `eventType` | string | Yes | Event |
431-
| `departmentIds` | string | No | Restrict events to these departments. Leave empty for all departments. |
495+
| `triggerDepartmentIds` | string | No | Restrict events to these departments. Leave empty for all departments. |
432496
| `fields` | string | No | For Ticket Updated: only fire when one of these fields changes \(max 5\). Previous values are included in the payload. |
433497
| `direction` | string | No | Thread Direction |
434498

apps/sim/app/api/tools/zoho_desk/organizations/route.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
44
import { zohoDeskListOrganizationsContract } from '@/lib/api/contracts/tools/zoho-desk'
55
import { parseRequest } from '@/lib/api/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7+
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
78
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
89
import { assertZohoUrl } from '@/tools/zoho_desk/host-allowlist'
910
import { getZohoDeskApiBase, getZohoDeskErrorMessage } from '@/tools/zoho_desk/utils'
@@ -44,16 +45,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4445
// The organizations endpoint is the one Desk call that does not require an
4546
// orgId header, so it can bootstrap the organization selector before a
4647
// portal has been chosen.
47-
const response = await fetch(organizationsUrl.toString(), {
48+
// Mirrors the attachment route: the initial host is allowlisted, but a
49+
// Zoho-side redirect would otherwise be followed with the OAuth token
50+
// attached and no IP pinning. secureFetchWithValidation pins the resolved
51+
// IP, blocks private/reserved targets on every hop, and drops the token if
52+
// a redirect leaves the original origin.
53+
const response = await secureFetchWithValidation(organizationsUrl.toString(), {
4854
method: 'GET',
4955
headers: {
5056
Authorization: `Zoho-oauthtoken ${accessToken}`,
5157
'Content-Type': 'application/json',
5258
},
53-
signal: AbortSignal.timeout(15_000),
59+
timeout: 15_000,
60+
stripAuthOnRedirect: true,
5461
})
5562

56-
const data = await response.json().catch(() => ({}))
63+
// secureFetchWithValidation types the body as `unknown`; Zoho wraps the list
64+
// in `{ data: [...] }`, which is narrowed below before use.
65+
const data: { data?: unknown } = await response
66+
.json()
67+
.then((body) => (body && typeof body === 'object' ? (body as { data?: unknown }) : {}))
68+
.catch(() => ({}))
5769
if (!response.ok) {
5870
// Surface the failure instead of returning an empty 200, which would make
5971
// the org dropdown silently render empty on an auth/connectivity error.

0 commit comments

Comments
 (0)