# Zoho Desk setup for the Spun helpdesk bridge

Workflow: `spun-zoho-desk-bridge.n8n.json` (import into n8n 1.x). Reference facts:
`API-NOTES.md` in this folder (Zoho's public API documentation, read 10/2026, not yet
live-tested). General template notes, node map and the live-test checklist:
`../README.md`.

Every Zoho shape below is marked **verified** (stated in Zoho's documentation) or
**assumed** (the documentation is ambiguous or silent; the conservative choice is
taken). Treat the assumed items as the first things to check in the live run.

## 0. What you end up with

| Side | Item |
|---|---|
| Zoho | A self client in the API console, two ticket custom fields plus a "Spun LID" field on tickets and contacts (step 5), one webhook pointing at n8n, and one agent whose token the workflow uses. |
| n8n | The imported workflow, two credentials, the Settings node filled in, the workflow active. |
| Spun | An API key with `send_message` (plus `manage_conversations` if you want desk-side close, step 9) and a webhook subscription with a signing secret. |

Plan requirement: Zoho Desk webhooks need **Professional or Enterprise** (Free and
Standard allow none). Verified.

## 1. Data centre and base URLs

Zoho runs separate data centres. The desk API host and the accounts host must match
the one your Zoho account lives in:

| Account host you log in at | `zoho_desk_base` | OAuth accounts host |
|---|---|---|
| desk.zoho.eu | `https://desk.zoho.eu` | `https://accounts.zoho.eu` |
| desk.zoho.com | `https://desk.zoho.com` | `https://accounts.zoho.com` |
| desk.zoho.in | `https://desk.zoho.in` | `https://accounts.zoho.in` |
| desk.zoho.com.au | `https://desk.zoho.com.au` | `https://accounts.zoho.com.au` |

The Settings default is the EU host (IQSolutions is in Greece). Whether the `.eu` host
accepts the same paths as `.com` is assumed (Zoho documents one API against `.com`).

## 2. OAuth app (self client) and the n8n credential

1. Open the Zoho API console (`https://api-console.zoho.<tld>`), **Add Client**, type
   **Self Client**. Note the client id and client secret. Verified.
2. In n8n, create a credential of type **OAuth2 API** (the generic one), grant type
   **Authorization Code**:
   - Authorization URL: `https://accounts.zoho.<tld>/oauth/v2/auth`
   - Access Token URL: `https://accounts.zoho.<tld>/oauth/v2/token`
   - Client ID / Client Secret: from step 1
   - Scope: `Desk.tickets.ALL Desk.contacts.READ Desk.contacts.CREATE Desk.contacts.UPDATE Desk.search.READ Desk.basic.READ Desk.settings.READ`
   - Auth URI Query Parameters: `access_type=offline` (a refresh token is issued only
     with this; verified)
   - Authentication: **Body**
3. Add n8n's redirect URL to the client in the API console (for a self client Zoho
   shows the redirect field when you generate a code; if your console version has no
   redirect field for self clients, create a **Server-based Application** client
   instead and paste n8n's redirect URL there). Connect the credential and approve the
   scopes as the agent whose comments should count as "the integration" (see step 7).
4. Select this credential on every `ZOHO:` node (HTTP Request, Authentication = Generic
   Credential Type, OAuth2 API).

**Assumed:** the generic OAuth2 credential sends `Authorization: Bearer <token>`. Zoho's
documentation shows `Authorization: Zoho-oauthtoken <token>` for Desk; other Zoho
products document both forms as accepted. If a `ZOHO:` node answers 401 `INVALID_OAUTH`
with a valid token, switch to the manual variant: remove the OAuth2 credential from the
`ZOHO:` nodes, add one HTTP Request node at the start of each lane that POSTs
`refresh_token`, `client_id`, `client_secret`, `grant_type=refresh_token` to the token
URL, and add a header `Authorization: Zoho-oauthtoken {{ access_token }}` to each
`ZOHO:` node. Zoho allows at most 10 refreshes per 10 minutes per refresh token, so
cache the access token in workflow static data for 55 minutes (verified limit).

Scope notes (verified unless marked): `Desk.tickets.ALL` covers ticket read, create,
update, comments and `closeTickets`; `Desk.contacts.READ` + `Desk.contacts.CREATE`
cover the contact lane; `Desk.contacts.UPDATE` covers the one-time phone update of a
privacy-ID contact (step 5; a token without it leaves that contact without a phone and
the execution shows the 403 in `phone_added_error`); `Desk.search.READ` covers `/contacts/search`;
`Desk.basic.READ` covers `/organizations` and `/departments`; `Desk.settings.READ` is
the scope for `/fields` (assumed; `Desk.fields.READ` is the alternative if the console
offers it). Creating the webhook through the API works with `Desk.events.ALL` (verified
live 2026-10-07; `Desk.webhooks.ALL` was not needed); the Zoho UI works as well.

## 3. orgId

`GET https://desk.zoho.<tld>/api/v1/organizations` with the credential lists your
organisations; copy the `id` into `zoho_org_id`. Every `ZOHO:` node sends it as the
`orgId` header (verified for multi-org accounts; harmless otherwise, so always sent).

## 4. departmentId

`GET /api/v1/departments` lists departments; copy the `id` of the one that should
receive WhatsApp tickets into `zoho_department_id`. Verified.

## 5. The two ticket custom fields

In Zoho Desk: Setup, Customization, Layouts and Fields, Tickets, the layout of your
department. Add:

| Field | Type | Purpose |
|---|---|---|
| Spun Chat ID | Single line (text), 100 characters | Holds the Spun `chat_id` (for example `306900000000@s.whatsapp.net`). The workflow finds the ticket by it. Required. |
| Spun Line | Single line (text) | Holds the Spun line (`integration_id`) that carried the chat. Optional; useful when one org has several WhatsApp lines. |

API alternative (verified live 2026-10-07): `POST /api/v1/organizationFields?module=tickets`
with `{"displayLabel": "Spun Chat ID", "type": "Text", "maxLength": 100}` answers 201
and the body already carries the generated `apiName`; the field lands on the
department's default layout.

Then read the API names: `GET /api/v1/fields?module=tickets&departmentId=<id>`; find
the two entries with `isCustomField: true` and copy their `apiName` into
`zoho_cf_chat_id` and `zoho_cf_line`. **Never guess the name**: Zoho generates it
(`cf_spun_chat_id` and `cf_spun_line` were the derived names in the live run, but read
them). A field that is not on the department's layout is rejected on create. Verified.

Leave `zoho_cf_line` on its `PASTE_` placeholder to skip the second field; the workflow
only sends it when the value no longer starts with `PASTE`.

### 5.1 The third field: Spun LID (privacy-ID chats)

WhatsApp hides some customers' numbers behind a privacy ID (an "LID", chat id
`<digits>@lid`). Spun reports such a chat with `peer_phone_e164: null`, `peer_lid` (the
digits) and `identity_state: lid_unlinked`; once Spun learns the phone it reports the
same chat with the phone and `identity_state: lid_linked`. The digits are not a phone
number. Background: `../README.md`, "WhatsApp privacy IDs (LID)".

Add a **Spun LID** field (Single line, 50 characters) twice:

| Where | Settings key | Purpose |
|---|---|---|
| Ticket layout of the department (as above) | `zoho_cf_lid` | Shows agents which privacy ID the ticket belongs to, and names the contact again after the workflow's memory is reset (the newest ticket carrying the privacy id is searched by this field). Required for a privacy-ID person to stay one contact. |
| Contacts: Setup, Customization, Layouts and Fields, Contacts | `zoho_contact_cf_lid` | The workflow writes the privacy id on the contact and tries to search contacts by it (`GET /contacts/search?customField1=<apiName>:<digits>`). Zoho refused that search with 403 on the portal we tested (a field created through the API), so treat the search as a bonus: the ticket field above is the reliable path. The field itself is still useful: agents see the privacy id on create. Recommended. |

Read the contact field's API name with `GET /api/v1/fields?module=contacts`. Leave a key
on its `PASTE_` placeholder to skip that field.

What the contact lane does for a privacy-ID chat:

1. The workflow's own map is checked first (`lid:<digits>`, then the phone).
2. Not in the map: with `zoho_contact_cf_lid` set, contacts are searched by the Spun LID
   contact field and a hit is accepted only when the field holds exactly these digits
   (Zoho may refuse this search with 403; the workflow then treats it as "not found").
3. Still not found: the newest ticket whose Spun LID ticket field (`zoho_cf_lid`) holds
   these digits names the contact (`GET /tickets/search?customField1=<apiName>:<digits>`,
   verified live). This is what keeps one person one contact after the workflow's
   memory is reset or the template is re-imported.
4. Still not found: the contact is created with the name (or `privacy ID <digits>` when
   WhatsApp gave no name), the Spun LID field and no phone or mobile.
5. When a later event of that chat arrives with `identity_state: lid_linked` and a
   phone, the phone lane runs first; a miss there sends a chat that has a privacy id
   through steps 2 and 3 before anything is created, and a contact found that way gets
   the phone and mobile PATCHed once (`Desk.contacts.UPDATE`, verified live). One attempt
   per privacy ID, success or not; a failure is in the execution's `phone_added_error`.

The ticket lane is unchanged: the ticket is found by the Spun Chat ID field, and the
chat id of a privacy-ID chat is `<digits>@lid`. Agent replies go back to that chat id.

## 6. Channel value

Ticket create sends `channel: <zoho_channel_value>`. Zoho's picklist differs between
portals; in the same `/fields` response find the field `channel` and its
`allowedValues`. Use `WhatsApp` if it is listed, otherwise keep the default `Chat`.
Assumed (the documentation does not list the default values).

Also from `/fields`: the `status` field's `allowedValues` with their types. Put the
label of an **Open-type** status (usually `Open`) into `zoho_open_status_label`; the
reopen path PATCHes the ticket to it. Verified that the status must be a label from the
list; the default label is assumed.

## 7. The integration agent id

Comments posted through the API appear as written by the agent who authorised the
token (verified). The workflow therefore prefixes inbound text with `[WhatsApp] Name
(+phone):` (`(privacy ID <digits>)` for a privacy-ID chat with no known phone, step 5.1)
and must ignore that agent's own public comments, otherwise every inbound
message would be sent back. `GET /api/v1/myinfo` (or `/agents`) gives the agent `id`;
put it in `desk_integration_user_id`. Best practice: a dedicated agent licence named
"WhatsApp (Spun)" so human agents' replies are never filtered.

**Single-agent portals (verified live 2026-10-07):** when the only agent is also the
one who authorised the token, every human reply is "written by the integration user"
and the Reply filter drops it. Leave `desk_integration_user_id` empty in that case. The
loop stays closed by the other guards: the webhook's `ignoreSourceId` suppresses
`Ticket_Comment_Add` for the workflow's own comments, the inbound prefix filter drops
any echo that slips through, and the workflow's own sends are recognised on
`message.outbound` by message id or, when Spun reports the id as `unknown`, by chat id
plus text within 15 minutes.

## 8. The Zoho webhook (agent replies to n8n)

1. Generate a UUID (any generator) and put it in `zoho_source_id`. The workflow sends
   it as the `sourceId` header on every write to Zoho; the webhook is told to ignore
   events with that source, so our own comments never come back as events. Verified.
2. Activate the n8n workflow and copy the production URL of the **Helpdesk reply**
   node (`.../webhook/zoho-desk-reply-<your random suffix>`).
3. In Zoho Desk: Setup, Developer Space, Webhooks, **Create Webhook**. Name it, paste
   the URL, enable it, and subscribe to:
   - **Ticket_Comment_Add** for your department (public agent comments are the reply
     path).
   - **Ticket_Thread_Add**, direction **out**, for your department (optional; an agent
     "Reply" that is not an email also reaches WhatsApp; email threads are ignored by
     the adapter).
   - **Ticket_Update** with previous state enabled (optional, for desk-side close; see
     "Desk-side close" below).
   - Ignore source id: the UUID from step 1 (the field is `ignoreSourceId` in the API).
   Zoho validates the URL before saving the webhook (a single GET was observed in the
   live run of 2026-10-07; the documentation also mentions a POST) and expects 200
   within 5 seconds; the n8n node answers both. Verified.
4. Copy the webhook `id` Zoho shows into `zoho_webhook_id`.

API alternative (`POST /api/v1/webhooks`, scope `Desk.events.ALL`, verified live;
`includePrevState` is the key that makes `prevState` arrive on `Ticket_Update`):

```json
{
  "name": "Spun WhatsApp bridge",
  "url": "https://YOUR-N8N/webhook/zoho-desk-reply-RANDOM",
  "isEnabled": true,
  "ignoreSourceId": "YOUR-UUID",
  "subscriptions": {
    "Ticket_Comment_Add": { "departmentIds": ["DEPT_ID"] },
    "Ticket_Thread_Add": { "departmentIds": ["DEPT_ID"], "direction": "out" },
    "Ticket_Update": { "departmentIds": ["DEPT_ID"], "includePrevState": true }
  }
}
```

**Authenticity.** Zoho signs deliveries with an `X-ZDesk-JWT` header (RS256, keys at
`https://desk.zoho.com/.well-known/jwks.json`; the EU host's JWKS path returned not
found when checked). The n8n Crypto node cannot verify RS256, so the workflow reads the
JWT's `iss` (`orgId:<id>`) and `aud` (`webhookId:<id>`) claims and drops deliveries
that do not match Settings, without verifying the signature. The long random webhook
path is the real secret. To verify the signature as well, add n8n's JWT node (Verify,
JWKS) in front of the adapter. Claim names are assumed from Zoho's description.

**Payload shape (verified):** a JSON **array** of `{eventType, orgId, eventTime,
payload}`; for `Ticket_Comment_Add` the payload has `id`, `ticketId`, `content` (HTML),
`contentType`, `isPublic`, `commenterId`, `commenter: {type: "AGENT" | ...}`. The
ticket's custom fields are not in the payload, so the workflow keeps its own
chat_id map and falls back to `GET /api/v1/tickets/{id}` to read the custom field.

## 9. Desk-side close (optional, verified live 2026-10-07)

When `close_in_spun_when_desk_closes` is true and the webhook subscribes to
`Ticket_Update`, closing a ticket in Zoho archives the conversation in Spun through
`POST /api/integrations/conversations/close` (reason `closed_in_zoho_desk`), and
reopening it calls `/reopen`. Requirements and caveats:

- The Spun API key needs the `manage_conversations` scope: tick "Close & Reopen
  Conversations" under Manage API Keys when creating the key. The two endpoints are
  live on api.spun.com since server build 2026-10-07T0316. A 403 from them means the
  key lacks the scope; a 404 means no conversation matched the phone or chat id.
- `Ticket_Update` payload (verified live): the ticket object (`id`, `ticketNumber`,
  `status`, `statusType`, `modifiedTime`, `cf`, ...) under `payload`, and the previous
  values under `prevState` when the subscription was created with
  `"includePrevState": true` (the UI's "previous state" option). Without `prevState` the
  adapter still detects a close (`statusType` now Closed) but cannot detect a reopen
  reliably; every update of an open ticket would then look like a possible reopen,
  which the state map rejects unless the ticket was marked closed.
- `Ticket_Update` also fires for changes the workflow itself makes (a comment it adds,
  its `closeTickets` call) even though they carry the `sourceId` header; only
  `Ticket_Comment_Add` is suppressed by `ignoreSourceId`. The adapter ignores those
  events: no status change, or the state map already says Spun closed the ticket
  ("Not closing in Spun"). The workflow's own reopen PATCH produced no event.
- `POST /closeTickets` answers 204 with an empty body. The close node reads the response
  as autodetect for that reason; a JSON response format makes the HTTP node fail on the
  empty body after the ticket was already closed.

## 10. Spun side

1. Webhooks page, Manage API Keys: create a key with `send_message` (and
   `manage_conversations` for step 9). Copy it once; create the n8n Header Auth
   credential `Authorization` = `Bearer wap_...` and select it on the three `SPUN:`
   nodes.
2. Webhooks page: create a subscription to the production URL of the **Spun events**
   node with the events `message.inbound`, `message.outbound`, `conversation.closed`,
   `conversation.reopened`, payload template **raw**, and a signing secret. Paste the
   secret into `spun_webhook_secret`. Each delivery costs one AI token whether or not
   the workflow turns it into a ticket (verified live 2026-10-07: 22 deliveries, 22
   tokens), so limit the subscription itself rather than the workflow: on the same
   Webhooks page set **Direct chats only** (skips group chats, newsletters and status
   updates) and, for a pilot, **Only these numbers** (the E.164 numbers you want
   tickets for). Events the subscription filters out are never delivered and never
   charged; the webhook card shows them as "N filtered". The n8n `only_from_phones`
   and `skip_groups` settings still work but decide after delivery, so they do not
   reduce the token cost. The same filters are available through the API as
   `filter_peer_phones` and `filter_direct_only` on `POST` / `PATCH /webhooks`.
   Both number filters match phones only: a privacy-ID chat with no known phone
   (step 5.1) never matches **Only these numbers** or `only_from_phones`, so a pilot
   limited to numbers leaves such chats out. **Direct chats only** keeps them.
3. Send a WhatsApp message to the line from another phone and follow the Delivery Log
   (Webhooks page) and the n8n executions list. The live-test checklist in
   `../README.md` lists the expected result of every step.

## 11. Settings node, all values

| Key | Value |
|---|---|
| `spun_api_base` | `https://api.spun.com/api/integrations` |
| `spun_webhook_secret` | the subscription's signing secret |
| `zoho_desk_base` | step 1 |
| `zoho_org_id` | step 3 |
| `zoho_department_id` | step 4 |
| `zoho_cf_chat_id`, `zoho_cf_line` | step 5 (API names) |
| `zoho_cf_lid`, `zoho_contact_cf_lid` | step 5.1 (API names of the Spun LID ticket field, required for privacy-ID dedupe, and the contact field, optional) |
| `zoho_channel_value` | step 6 (`Chat` default) |
| `zoho_open_status_label` | step 6 (`Open` default) |
| `zoho_source_id` | step 8, your UUID |
| `zoho_webhook_id` | step 8 |
| `desk_integration_user_id` | step 7 |
| `only_from_phones` | `[]` = every chat. Pilot mode: list E.164 numbers with `+` and only those chats become tickets; everything else ends at "Skipped" in n8n. Phones only: a privacy-ID chat with no known phone is skipped in pilot mode. Cheaper: put the same numbers in the subscription's **Only these numbers** filter (step 10.2) so the other chats are never delivered or charged, and leave this `[]` |
| `skip_groups` | `true` (group chats have no single person to make a contact for). Cheaper: **Direct chats only** on the subscription, so group events are never delivered or charged. "Skipped (group or newsletter)" is the only skip outside pilot mode; privacy-ID chats are handled |
| `inbound_prefix` | `[WhatsApp]` (also the echo filter) |
| `outbound_prefix` | `[Sent from Spun]` (private notes) |
| `desk_close_reason`, `desk_reopen_reason` | `closed_in_zoho_desk`, `reopened_in_zoho_desk` (loop guard for step 9) |
| `close_in_spun_when_desk_closes` | `false` until step 9 is set up and its first close is checked |
| `dedupe_hours` | `24` |

## 12. Zoho API usage and limits

Roughly 8 API credits for a new conversation (two searches, create contact, read
ticket, create ticket) and 1 per follow-up message; the state maps keep it that low. A
new privacy-ID chat costs one search (3 credits) less, or two less without
`zoho_contact_cf_lid`; the one-time phone update adds 1.
Concurrency on trial and Free editions is 5 requests at a time; 429 answers carry
`Retry-After` and the HTTP nodes keep the error in the item (Never Error on), so an
execution shows the Zoho error body instead of failing silently. Verified figures are
in `API-NOTES.md` section 10. In the live run of 2026-10-07 (trial portal) the
`X-Rate-Limit-Remaining-v3` header stayed at 1499 on every call, so credit use could
not be read from headers; count the calls per lane instead.

## 13. What was verified and what was assumed

| Item | Status |
|---|---|
| OAuth token endpoint, `access_type=offline`, 10 refreshes per 10 minutes | Verified |
| `Authorization: Zoho-oauthtoken` accepted | Verified |
| `Authorization: Bearer` accepted (what the n8n OAuth2 credential sends) | Verified live 2026-10-07; an expired access token was refreshed by n8n and the call succeeded |
| `orgId` header | Verified for multi-org; always sent |
| EU host paths identical to `.com` | Assumed |
| `GET /contacts/search?phone=` and `?mobile=`; 204 / empty = not found; index lag | Verified |
| `POST /contacts` with `lastName`, `phone`, `mobile` | Verified |
| `POST /contacts` without `phone` / `mobile`, with `cf` (privacy-ID contact) | Verified live 10/2026 (`lastName` is the only required field) |
| `GET /contacts/search?customField1=<apiName>:<digits>` finds a contact by the Spun LID field | REFUSED live 10/2026 on a field created through the API: `403 FORBIDDEN "You are not authorized to access <field>"`, still after minutes. The workflow treats it as "not found" and relies on the ticket fallback below; a portal where the search is allowed simply finds the contact one step earlier |
| `GET /tickets/search?customField1=<ticket Spun LID apiName>:<digits>&sortBy=-createdTime` names the contact of a privacy-ID chat (`contactId` of the newest ticket) | Verified live 10/2026: with empty workflow memory the same privacy id reused its contact instead of creating a second one |
| `PATCH /contacts/{id}` with `phone`, `mobile` (scope `Desk.contacts.UPDATE`) | Verified live 10/2026 (response echoes the contact `id`); one attempt per privacy id |
| `POST /tickets` with `subject`, `description`, `departmentId`, `contactId`, `channel`, `phone`, `cf` | Verified; `channel` value assumed (read from `/fields`) |
| Custom field API names must come from `/fields`; field must be on the layout | Verified |
| `GET /contacts/{id}/tickets?sortBy=-createdTime` as the fallback lookup | Verified; status filtered client-side |
| `GET /tickets/{id}` returns `statusType` | Verified |
| `POST /tickets/{id}/comments` with `content`, `isPublic`, `contentType: plainText`, 32,000 chars | Verified |
| Comments appear as the token's agent | Verified |
| `POST /closeTickets` `{ids: [...]}` | Verified live; answers 204 with an empty body |
| `GET /tickets/search?customField1=<apiName>:<chat_id>` finds the ticket for a value with `@` and `.` | Verified live (probe ticket) |
| `POST /organizationFields?module=tickets` creates the custom fields and returns `apiName` | Verified live |
| Comment content read back through the API is HTML-escaped (`&` becomes `&amp;`) even for `plainText` | Observed live; UI rendering not checked |
| Reopen by PATCH `status` to an Open-type label | Verified; default label assumed |
| Webhooks need Professional or higher; URL validation before save (one GET observed live); 200 within 5 s; 410 unsubscribes | Verified |
| Webhook payload is an array; comment payload fields | Verified |
| `ignoreSourceId` + `sourceId` header loop guard | Verified |
| `X-ZDesk-JWT` RS256 with JWKS on `.com` | Verified; `.eu` JWKS path not found; claim names assumed |
| `Ticket_Update` event exists | Verified (Zoho webhook document, 10/2026) |
| `Ticket_Update` payload has `statusType`; `prevState` arrives with `"includePrevState": true` | Verified live 2026-10-07 |
| `Desk.settings.READ` as the scope for `/fields` | Assumed (`Desk.fields.READ` alternative); the live run used `Desk.settings.ALL` |
| Webhook creation scope | Verified live: `Desk.events.ALL` suffices for `POST /webhooks` |
| Desk-side close and reopen reach Spun (`closed_in_zoho_desk`, `reopened_in_zoho_desk`) | Verified live 2026-10-07 |
