# Spun helpdesk bridge: WhatsApp conversations as helpdesk tickets

Importable n8n workflows that turn WhatsApp conversations in Spun into tickets in a
helpdesk and send public agent replies back to the customer on WhatsApp. One generic
template holds the whole flow; each helpdesk is an instance of it with the helpdesk
calls swapped.

| Folder | What |
|---|---|
| `generic/spun-helpdesk-bridge.n8n.json` | The template. Helpdesk calls are placeholder HTTP nodes named `DESK: ...`, each followed by a small adapter Code node. Adapt both to your desk. |
| `zoho-desk/spun-zoho-desk-bridge.n8n.json` | The Zoho Desk instance. `SETUP.md` next to it is the step-by-step setup; `API-NOTES.md` is the verified Zoho reference the nodes are built from. |
| `zendesk/spun-zendesk-bridge.n8n.json` | The Zendesk instance, same layout: `SETUP.md` (setup, triggers, limits) and `API-NOTES.md` (verified Zendesk reference). |
| `freshdesk/spun-freshdesk-bridge.n8n.json` | The Freshdesk instance, same layout: `SETUP.md` (setup, automation rules, limits) and `API-NOTES.md` (verified Freshdesk reference). |
| `hubspot-service-hub/spun-hubspot-bridge.n8n.json` | The HubSpot Service Hub instance, same layout: `SETUP.md` (setup, ticket properties, app webhooks, the CRM contact sync, limits) and `API-NOTES.md` (verified HubSpot reference). |
| `jira-service-management/spun-jira-service-management-bridge.n8n.json` | The Jira Service Management instance, same layout: `SETUP.md` (setup, the WhatsApp number field, transitions, the admin webhook, limits) and `API-NOTES.md` (verified Jira reference). |

Customer-facing guides: <https://docs.spun.com/guides/helpdesk-integrations> (the
events, signature, retries, loop guards) and
<https://docs.spun.com/guides/helpdesk-zoho-desk> (the Zoho Desk walk-through) and
<https://docs.spun.com/guides/helpdesk-zendesk> (the Zendesk walk-through) and
<https://docs.spun.com/guides/helpdesk-freshdesk> (the Freshdesk walk-through) and
<https://docs.spun.com/guides/helpdesk-hubspot-service-hub> (the HubSpot Service Hub walk-through) and
<https://docs.spun.com/guides/helpdesk-jira-service-management> (the Jira Service Management walk-through).

Status 10/2026: the workflows are built from Spun's live code and from the helpdesks'
public API documentation. No end-to-end run against a real helpdesk has happened yet
(decision D6 in `.devin/plans/helpdesk-integrations-2026-10.md`). The checklist for
that run is at the end of this file.

## What the flow does

Five moves, the same for every helpdesk:

1. **Contact.** Find the helpdesk contact by the customer's phone number
   (`peer_phone_e164`), or create it.
2. **Ticket.** Find the open ticket for this WhatsApp chat (a ticket custom field holds
   the Spun `chat_id`), or create one. The first message becomes the ticket description.
3. **Append.** Every later customer message becomes a public comment on the ticket.
   Media arrives as a link (signed, valid 24 hours) with type, name and size, or as a
   "still processing" line when `media_pending` is true. Messages sent from the Spun
   inbox or the phone become private notes.
4. **Reply.** A public agent comment on the ticket goes to the customer through
   `POST /api/integrations/send-message`, with the comment id as `idempotency_key`.
5. **Close.** Archiving the conversation in Spun closes the ticket; unarchiving reopens
   it. Optionally, closing the ticket in the helpdesk closes the conversation in Spun
   (`POST /api/integrations/conversations/close`, see "Limits").

## Requirements

- n8n 1.x (self-hosted or cloud). Only core nodes are used: Webhook, Code, Crypto,
  Extract From File, Switch, If, HTTP Request, Respond to Webhook, No Operation.
  No community node is required. If you prefer the Spun community nodes
  (`n8n-nodes-spun`, nodes `Spun` and `Spun Trigger`, credential `spunApi`), they can
  replace the `SPUN:` HTTP nodes one for one; the template stays on HTTP Request so it
  imports anywhere.
- A Spun API key (Webhooks page, Manage API Keys) with the scopes `send_message` and,
  for the optional desk-side close, `manage_conversations`.
- A Spun webhook subscription with a signing secret (see "Spun side" below).
- Helpdesk API credentials and, on the helpdesk side, a webhook or automation that
  fires on new public agent comments. Plan requirements differ per desk (Zoho Desk:
  Professional or higher for webhooks; Zendesk: an admin creates the webhook and
  triggers; Freshdesk: Growth or higher for the webhook action in automation rules,
  which an admin creates; HubSpot: Service Hub plus an app that can hold webhook
  subscriptions, a legacy private app created before October 26, 2026 or a
  project-based app, see `hubspot-service-hub/SETUP.md`; Jira Service Management: an
  integration account that is an agent on the service project, and a Jira admin once
  for the webhook, see `jira-service-management/SETUP.md`).

## Import and configure

1. In n8n: Workflows, Import from File, pick the JSON.
2. Credentials: create them in n8n, then select them on every `SPUN:` and `DESK:`
   (or `ZOHO:`, `ZENDESK:`, `FRESHDESK:`, `HUBSPOT:`, `JIRA:`) node. Credential placeholders in the JSON are named
   `REPLACE_AFTER_IMPORT` and never contain values.
   - Spun: Header Auth credential, header name `Authorization`, value
     `Bearer wap_...` (the key is shown once when created).
   - Helpdesk: whatever the desk uses (Header Auth for token desks, a generic OAuth2
     credential for Zoho Desk, see `zoho-desk/SETUP.md`; a generic OAuth2 credential
     with the client credentials grant for Zendesk, see `zendesk/SETUP.md`; a Basic Auth
     credential with the API key as user and `X` as password for Freshdesk, see
     `freshdesk/SETUP.md`; a Header Auth credential with `Bearer` and a service key or
     legacy private app token for HubSpot, see `hubspot-service-hub/SETUP.md`; a Basic Auth
     credential with the integration account's email as user and a classic API token as
     password for Jira Service Management, see `jira-service-management/SETUP.md`).
3. Open the **Settings** node and replace every `PASTE_...` value. This is the only Code
   node you edit. Values are copied onto every item, so later nodes read
   `$json.settings.<name>` whatever trigger fired.
4. Replace the webhook path suffix of the **Helpdesk reply** node with a long random
   string. That path is the only secret on the desk side (most desks cannot sign).
5. Activate the workflow. Copy the production URLs of **Spun events** and **Helpdesk
   reply** from the two webhook nodes.
6. Spun side: create the webhook subscription (next section). Helpdesk side: register
   the reply webhook (per-desk SETUP).

### Spun side

On the Webhooks page (or with `POST /api/integrations/webhooks`, scope
`manage_webhooks`) create a subscription with:

- URL: the production URL of the **Spun events** node.
- Events: `message.inbound`, `message.outbound`, `conversation.closed`,
  `conversation.reopened`.
- Payload template: **raw**. The other templates rename fields for specific CRMs.
- A signing secret. Through the API the 64-character secret is returned once in the
  create response; on the Webhooks page you can type your own or rotate one. Paste it
  into `spun_webhook_secret` in Settings.

Delivery facts the workflow relies on (from the Spun server code): the body is
`{event_id, event, timestamp, org_id, data}`; headers `X-Webhook-Event`,
`X-Webhook-Delivery` (= `event_id`) and `X-Webhook-Signature: sha256=<hex HMAC-SHA256
of the raw body>`; 10 second timeout; three attempts (at once, after 5 s, after 30 s);
a 4xx answer is not retried; deliveries are token-metered and skipped when the budget
is exhausted (`Skipped: AI token budget exhausted` in the Delivery Log).

## Node map

Sticky notes inside the workflow explain each lane. Node name prefixes: `SPUN:` calls
the Spun Actions API, `DESK:`/`ZOHO:`/`ZENDESK:`/`FRESHDESK:`/`HUBSPOT:`/`JIRA:` calls the helpdesk (the only nodes to swap),
`State:` reads or writes the workflow's own maps. Everything else is desk-agnostic.

| Lane | Nodes | What happens |
|---|---|---|
| Entry | Spun events (Webhook, raw body on), Helpdesk reply (Webhook, GET+POST, responds immediately), Settings, Route by trigger | Both triggers feed the Settings node; a Switch on the webhook path sends Spun deliveries to lane 1 and desk calls to lane 6. GET on the reply URL is answered 200 and dropped (desks that validate the URL first). |
| 1 Verify | Raw body to text, HMAC of raw body (Crypto), Check signature + parse, Signature valid?, Ack 200 / Reject 401 | HMAC-SHA256 over the exact raw bytes, constant-time compare in Code, then 200 within the 2 second target. Everything after Ack 200 runs after Spun has its answer, so a slow desk never causes a retry. A bad signature gets 401 (Spun does not retry 4xx). |
| 2 Dedupe and route | State: seen before?, New delivery?, Route by event, Duplicate, ignored, Other event, ignored | Dedupe on `event_id` and on `message_id` (24 h window, `dedupe_hours`). Switch on `event`. |
| 3 Contact | Prepare message, Handle this message?, State: lookup contact + ticket, Contact known?, Contact search lane, DESK: search contact by phone, Pick matching contact, DESK: search contact by Spun LID, Pick matching contact (Spun LID), Contact found by Spun LID?, Contact found?, Contact to create, DESK: create contact, Read new contact id, State: remember contact | Groups and newsletters are skipped ("Skipped (group or newsletter)"). Own contact map first (by phone, or `lid:<digits>` for a privacy-ID chat), desk search second (by phone, or by the optional Spun LID contact field; exact match only, never search relevance), create third. See "WhatsApp privacy IDs (LID)". |
| 4 Ticket | Open ticket known?, DESK: read ticket status, Ticket still open?, Still open?, State: mark ticket closed (desk closed it), DESK: find open ticket by chat_id, Pick open ticket, Open ticket found?, State: remember found ticket, DESK: append message as comment, DESK: create ticket, Read new ticket id, State: remember new ticket, State: remember message | Own chat_id to ticket map first; a known ticket is re-read once so a ticket an agent closed in the desk gets a new ticket instead of a comment on a closed one. Fallback lookup by the chat_id custom field, then create. |
| 5 Echo, close, reopen | Own send or ticket?, Echo of our own reply?, Ticket known for this chat?, DESK: add private note (sent from Spun), Prepare close, Close ticket in desk?, DESK: close ticket, Prepare reopen, Reopen ticket in desk?, DESK: reopen ticket, and their State: nodes | `message.outbound` that echoes our own send is skipped; other outbound messages become private notes. `conversation.closed` and `conversation.reopened` act on the mapped ticket unless the `reason` is the one this workflow sends (own close echo). |
| 6 Agent replies | DESK adapter: normalize reply event, Route by desk event, Reply filter, Forward this reply?, Chat id resolved?, DESK: read ticket (find chat id), Read chat id from ticket, SPUN: send-message, State: remember sent message | The adapter turns the desk payload into a common shape; the filter keeps public agent comments only, drops the integration user's own comments, echoes of inbound messages, empty text and comments already forwarded. `idempotency_key` = desk comment id. |
| 6b Desk close to Spun | Prepare Spun close, Close in Spun?, SPUN: close conversation, Prepare Spun reopen, Reopen in Spun?, SPUN: reopen conversation, and their State: nodes | Only when `close_in_spun_when_desk_closes` is true. Sends `reason` = `desk_close_reason` so lane 5 recognises the echo. |

Node counts: generic 82 working nodes + 8 notes; Zoho Desk 90 + 8 (the Zoho instance
searches by `phone` and by `mobile`, and by its Spun LID contact field for privacy-ID
chats, then adds the phone once when it becomes known); Zendesk 89 + 8 (user lookup by its own external
id and by phone, plus a signature check and a comments read in lane 6, because the
Zendesk trigger sends ids only); Freshdesk 89 + 8 (contact lookup by phone and by
mobile, a shared-secret header check and a conversations read in lane 6, because the
Freshdesk automation rule sends ids only, and a status reset on notes to pending or
resolved tickets); HubSpot Service Hub 102 + 9 (contact lookup by phone that never
creates a contact while the Spun CRM contact sync is connected, a ticket-to-contact
association and communications on the timeline, v1 and v3 signature checks and a
ticket read in lane 6, a reply property cleared after each send, and a setup helper
that lists the pipeline and stage ids); Jira Service Management 81 + 8 (no contact
lane: the integration account raises every request; a JQL fallback on the WhatsApp
number field, a request status re-read with an optional reopen transition, a hex HMAC
check, a JSM comment read when the payload lacks the text or visibility, and an issue
read for requests missing from the map).

### The adapter contract

`DESK adapter: normalize reply event` must output one item per desk event with:

```
kind          'agent_reply' | 'ticket_closed' | 'ticket_reopened' | 'ignore'
ticket_id     desk ticket id (string)
comment_id    desk comment id (string), used as idempotency_key
text          plain text (strip HTML here)
is_public     boolean
author_type   'agent' | 'customer' | 'system'
author_id     desk user id of the author, compared with settings.desk_integration_user_id
desk_event_id a stable id for the desk event (used for the close/reopen idempotency key)
```

Every other adapter (`Pick matching contact`, `Read new contact id`, `Ticket still
open?`, `Pick open ticket`, `Read new ticket id`, `Read chat id from ticket`) reads one
desk response and sets one or two fields. Each starts with an `// ADAPTER:` comment that
says what to return.

## WhatsApp privacy IDs (LID)

WhatsApp hides some customers' numbers behind a privacy ID (an "LID"): the chat id is
`<digits>@lid` instead of `<number>@s.whatsapp.net`, and the digits are **not** a phone
number. Every message payload says which kind of chat it is:

| `identity_state` | `peer_phone_e164` | `peer_lid` | Meaning |
|---|---|---|---|
| `phone` | the number | `null` | An ordinary chat. |
| `lid_linked` | the number | the digits | A privacy-ID chat whose number Spun knows (the contact was linked in Spun). |
| `lid_unlinked` | `null` | the digits | A privacy-ID chat with no known number. |
| `null` | `null` | `null` | Groups, newsletters and other non-person chats. |

What the templates do with them:

- Privacy-ID chats are handled, never skipped. Only groups and newsletters end at
  "Skipped (group or newsletter)".
- `Prepare message` outputs `phone` (or none), `lid`, `identity_state` and a `name`
  that falls back to `privacy ID <digits>`; the comment prefix reads
  `[WhatsApp] Name (privacy ID <digits>): text` while the number is unknown.
- The contact map stores such a contact under `lid:<digits>`. When a later message
  arrives as `lid_linked`, the map finds the same contact by that key and stores the
  phone as well, so one person never becomes two contacts.
- When the map is empty (re-import, reset), the desk is asked: first a contact search by
  the Spun LID contact field, then the newest ticket carrying the privacy id in the Spun
  LID ticket field (Zoho refused the contact-field search with 403 in the 10/2026 live
  run; the ticket route is the one that held). A chat that arrives with a phone but has
  a privacy id goes through those two lookups after the phone lookups miss, before any
  contact is created, and a contact found that way gets the phone once.
- Each desk keeps the digits in a third custom field, "Spun LID", next to the Spun
  chat id and the Spun line:

| Template | Contact found by | Phone added once known | Ticket field |
|---|---|---|---|
| Zoho Desk | Spun LID contact field (search), else create | Yes, one contact update | optional `zoho_cf_lid` |
| Zendesk | user `external_id` `spun-lid:<digits>`, else create | Yes, one `create_or_update` | optional `zendesk_lid_field_id` |
| Freshdesk | none (the contact list cannot filter on it): create with `unique_external_id` `whatsapp-lid:<digits>` | No | optional `freshdesk_lid_field` |
| HubSpot Service Hub | optional `spun_lid` contact property (search), else the create decision | No (it never edits contacts) | optional `hubspot_ticket_lid_property` |
| Jira Service Management | no contacts; the request carries the digits | n/a | optional `jira_lid_field_id`, also the fallback lookup and the reply route |
| generic | optional `desk_contact_lid_field` search, else create | No | your chat id field holds `<digits>@lid` |

- Where a desk cannot search contacts by the field, the workflow's map is the only
  dedupe: after a reset of the workflow's data, a new contact may be created (or
  refused, where the key is unique). Each desk's SETUP.md says which.
- **generic template:** set `desk_contact_lid_field` only when your desk can search
  contacts by a custom field; adapt `DESK: search contact by Spun LID` and
  `Pick matching contact (Spun LID)` like the phone search. `DESK: create contact` then
  sends `custom_fields[<field>] = <digits>`. A desk that requires a phone or email on
  every contact rejects privacy-ID chats in `Read new contact id`.
- **Replies** go to the chat id, so `<digits>@lid` works as `to` in `send-message`
  exactly like a phone chat.
- **Number filters are phone-only.** The Spun subscription's "Only these numbers"
  filter and the Zoho template's `only_from_phones` compare phone numbers, so a
  privacy-ID chat with no known number never matches them. "Direct chats only" keeps
  privacy-ID chats.

## Loop guards

The three guards from the docs guide, and where they live:

1. **Echo as private note, trigger on public replies only.** Lane 5 adds
   `message.outbound` as a private note; the desk webhook is registered for public
   comments, and the Reply filter drops anything not public or not by an agent, plus
   comments written by the integration user (`desk_integration_user_id`) and comments
   that start with `inbound_prefix` (our own inbound echoes).
2. **Skip your own sends.** `State: remember sent message` stores each `message_id`
   returned by `send-message`, and the chat id plus text as well, because Spun answers
   `message_id: "unknown"` for text sends (live 2026-10-07); `Own send or ticket?` skips
   a `message.outbound` with that id, or with the same chat id and text within 15
   minutes.
3. **One send per comment.** `idempotency_key` is the desk comment id (Spun replays the
   same response for 6 hours), and `sentComments` in state drops a comment already
   forwarded.

A fourth guard for closes: `conversation.closed`/`reopened` whose `reason` equals
`desk_close_reason`/`desk_reopen_reason` are our own requests coming back and are
ignored.

## State

Dedupe sets and the maps (phone or `lid:<digits>` to contact id, chat_id to ticket, own message ids, own
comment ids) live in n8n workflow static data (`$getWorkflowStaticData('global')`).
Facts to know:

- Static data persists only for **production** executions of an **active** workflow.
  Manual test runs from the editor do not save it. Test with real deliveries.
- It is saved at the end of an execution. Two executions running at the same moment do
  not see each other's writes, so a burst of first messages from a new customer can
  create two tickets. The desk fallback lookup (lane 4) catches the second one on the
  next message. For strict ordering set the workflow setting "Execution order" and run
  one n8n main process, or move the maps to an n8n Data Table, Redis or a database.
- Entries older than `dedupe_hours` are pruned on each run (sets only; the maps are
  kept). Clear state by deactivating and reactivating the workflow after an import, or
  by deleting the keys in a Code node.

A multi-worker n8n (queue mode) must move the state to a shared store; the `State:`
nodes are the only places to change.

## Limits (10/2026)

- **Desk-side close is off by default.** `POST /conversations/close` and `/reopen` are
  live on api.spun.com (server build 2026-10-07T0316, scope `manage_conversations`,
  granted by the "Close & Reopen Conversations" checkbox under Manage API Keys). The
  template still ships `close_in_spun_when_desk_closes` false because the desk's
  ticket-update webhook shape is documentation-only until the live run (Zoho:
  `Ticket_Update` payload assumed, SETUP step 9). Turn it on after that run confirms
  the shape; until then closing stays one-way, from Spun to the desk, and a "Resolved"
  label with `POST /apply-label` is an alternative marker.
- **Media pending.** When `media_pending` is true the file was not yet mirrored when
  the event fired. The event is not resent and the API has no "fetch this message by id"
  call, so the comment says "Media is still being processed in Spun. Open the
  conversation in Spun to view it." A later release may add a media-ready event.
- **Metering.** Webhook deliveries count against the org's AI token budget and are
  skipped when it is exhausted (decision D5, open). Watch the Delivery Log for
  `Skipped: AI token budget exhausted`. Every inbound message of the org is delivered
  and charged one token, group and newsletter chats included; the workflow's filters
  do not reduce deliveries (live 2026-10-07: 22 deliveries, 22 tokens).
- **One ticket per conversation until closed.** A message after a close opens a new
  ticket (the old one is never reopened by a new message). This matches the IQSolutions
  question in the mapping doc; change `Still open?` to reopen instead if the customer
  prefers.
- **Text only to WhatsApp.** Agent replies go out as text. Attachments on a desk
  comment are not forwarded (they need desk-side authentication to download);
  `POST /send-media` exists for a later version.
- **Groups skipped.** `skip_groups` true by default; group messages have no
  `peer_phone_e164`.
- **Pilot mode.** `only_from_phones` (Zoho template for now) lists E.164 numbers;
  while it is non-empty only those chats become tickets, so a live line can be
  trialled without turning every conversation into a ticket. Cheaper variant:
  set the same numbers as the Spun subscription's **Only these numbers** filter
  (and **Direct chats only**), so the other chats are never delivered or
  token-charged in the first place; the Zoho SETUP step 10.2 shows both.
- **Signing on the desk side.** Most desks do not sign webhooks; the long random
  webhook path is the secret. Zoho signs with a JWT (see its SETUP).

## Zapier and Make

The same flow in Zapier or Make: a Webhooks "Catch Raw Hook" (Zapier) or "Custom
webhook" (Make) trigger, a Code step for the HMAC check and dedupe (both tools lack
constant-time compare primitives; compare the hex strings and accept the small timing
leak, or terminate the signature check at the gateway), the desk's own Zapier or Make app for
search, create and comment, and a second Zap or scenario from the desk's "new comment"
trigger to a Webhooks POST to `send-message`. Store the maps in Zapier Storage or Make
Data Store. Zapier cannot answer the webhook with a status code of your choice; it
always acknowledges, which is fine for Spun.

## Live-test checklist (decision D6)

Nobody has run this end to end yet. Workers never create external accounts, so the
founder provides the environment. What is needed:

| Provide | Why |
|---|---|
| An n8n instance reachable over HTTPS (cloud or self-hosted), with a login for the tester | Hosts the workflow; Spun and the desk both call it. |
| A Zoho Desk trial (Professional or Enterprise trial, EU data centre preferred) with one department and one agent, and a Zoho API console self client (client id and secret) | The webhooks feature needs Professional or higher; the self client gives the OAuth tokens. |
| The Zoho `orgId`, the department id, and the two custom fields created on the ticket layout (`SETUP.md` steps 3 to 5) | Settings values. |
| A Spun org on a plan with the Developer Portal, one connected WhatsApp line, and an API key with `send_message` (plus `manage_conversations`, the "Close & Reopen Conversations" checkbox, for step 13) | The Spun side. |
| A test phone with WhatsApp that is not the connected line | Plays the customer. |

Run (expected observations in brackets; Delivery Log = Webhooks page in Spun; Zoho =
the trial's agent view):

1. Import `zoho-desk/spun-zoho-desk-bridge.n8n.json`, set credentials and Settings,
   activate. Create the Zoho webhook per SETUP step 8 [Zoho answers 200 with a webhook
   id; n8n shows one GET and one POST execution for the URL check].
2. Create the Spun subscription with the four events, raw template, a secret [the
   Webhooks page lists it, Delivery Log empty].
3. From the test phone, send "Hello, order 123 has not arrived" to the line
   [Delivery Log: one `message.inbound` row, 200, under 2 s. Zoho: a new contact with
   the test number, a new ticket in the department, subject "WhatsApp: Hello, order
   123 ...", description prefixed `[WhatsApp]`, custom field holding the chat id,
   channel as configured].
4. Send a second text [Delivery Log: `message.inbound` 200. Zoho: one new public
   comment on the same ticket, no second ticket].
5. Send a photo with a caption [Delivery Log: `message.inbound` 200. Zoho: a public
   comment with `[image] https://...` and the caption; open the link in a browser,
   it serves the image. If the comment says "still being processed" instead, the
   event fired within the 8 second mirror window; note it, it is the expected
   `media_pending` path].
6. Reply from the Spun inbox "We are checking" [Delivery Log: `message.outbound` 200.
   Zoho: a private note prefixed `[Sent from Spun]`].
7. In Zoho, post a **public** comment "Your parcel ships tomorrow" as the agent
   (on a single-agent portal leave `desk_integration_user_id` empty first, or the
   execution ends at "Reply filtered out", reason `written by the integration user`)
   [n8n: one execution from Helpdesk reply with `forward: true`. Test phone: the text
   arrives. Delivery Log: a `message.outbound` row whose execution ends at "Echo,
   nothing to add". Zoho: no new comment].
8. In Zoho, post a **private** comment [n8n execution ends at "Reply filtered out",
   reason `private note`. Phone: nothing].
9. Post the same public comment text again as a new comment [phone receives it once;
   a Zoho retry of the same comment id would end at `already forwarded`].
10. Archive the conversation in Spun [Delivery Log: `conversation.closed` 200. Zoho:
    ticket status type Closed].
11. Send a new text from the phone [Zoho: a NEW ticket; the closed one untouched].
12. Unarchive the conversation in Spun [Delivery Log: `conversation.reopened`. Zoho:
    the mapped (new) ticket unchanged, because it is already open; if you reopened a
    chat whose ticket was closed in step 10 the ticket is set back to the configured
    open status].
13. Only if `close_in_spun_when_desk_closes` is true and the key carries
    `manage_conversations`: close the new ticket in Zoho
    [n8n: Ticket_Update execution with `kind: ticket_closed`; Spun: the chat is
    archived; Delivery Log: `conversation.closed` with reason `closed_in_zoho_desk`,
    whose execution ends at "Nothing to close"].
14. Record: the Delivery Log rows (count, statuses, times), the token cost shown for
    the period, the Zoho API credits used (Setup, Developer Space, API usage), and
    every place where the observed shape differed from `API-NOTES.md`. Those
    differences are the input for the `W-DESK-LIVE` report and for turning the
    "assumed" rows in the TEMPLATE report into verified ones.

Stop conditions: a 401 from Spun on step 3 means the secret in Settings differs from
the subscription's; a 400 `INVALID_DATA` from Zoho on ticket create means the custom
field API name or the channel value is wrong (re-read `GET /api/v1/fields?module=tickets`);
a 410 from your n8n URL would unsubscribe the Zoho webhook, which this workflow never
sends.
