# Zendesk setup for the Spun helpdesk bridge

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

Every Zendesk shape below is marked **verified** (stated in Zendesk'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 |
|---|---|
| Zendesk | One integration agent (admin), one OAuth client, one webhook pointing at n8n, one trigger that calls it (two more optional triggers for desk-side close and reopen). |
| 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 8) and a webhook subscription with a signing secret. |

Plan requirement: creating webhooks and triggers needs an **admin**; trial accounts are
limited to 10 webhooks (verified). Which plans include webhooks was not checked; the
documentation read for `API-NOTES.md` names no plan restriction.

How Zendesk differs from the generic template (resolved here, details below):

- Zendesk cannot take a requester by phone. The workflow finds or creates a Zendesk
  **user** first and passes its id (step 4).
- The Spun chat id lives in the ticket's own **`external_id`** field, so no custom
  field is needed. Every ticket the workflow creates is also tagged `spun`.
- Every write the workflow makes is "via Web service (API)" and is done by the
  integration agent, even when the comment's author is the customer. The triggers
  exclude those writes with **Update via is not Web service (API)**.
- The trigger body carries ids only; the workflow reads the comment text through the
  API (one extra request per agent reply).
- **Closed** is final in Zendesk. A message after a close opens a **follow-up ticket**.
  A message on a **solved** ticket sets it back to open.

## 1. Subdomain

Your Zendesk URL is `https://<subdomain>.zendesk.com`. Put it, without a trailing
slash, into `zendesk_base`. Every API path is `/api/v2/...` under it. Verified.

## 2. Integration agent

Create (or pick) one agent for the integration, named for example "Spun WhatsApp".
Requirements (verified):

- **Admin**, or an agent in a custom role that may **manage end users**:
  `POST /api/v2/users/create_or_update` needs that permission. Ticket create and
  update need an agent.
- A dedicated agent keeps the workflow's own writes recognisable in the ticket
  history and keeps them out of the reply path: the workflow never forwards a comment
  written by this user (`desk_integration_user_id`).

## 3. OAuth client and the n8n credential

API tokens are deprecated in Zendesk (verified); the workflow ships with OAuth.

1. Sign in **as the integration agent**. Admin Center, Apps and integrations, APIs,
   OAuth clients, **Add OAuth client**. Choose the confidential client kind, give it a
   name and identifier, save, and copy the secret (shown once).
2. In n8n, create a credential of type **OAuth2 API** (the generic one):
   - Grant Type: **Client Credentials**
   - Access Token URL: `https://<subdomain>.zendesk.com/oauth/tokens`
   - Client ID: the client's identifier; Client Secret: from step 1
   - Scope: `tickets:read tickets:write users:read users:write` (one space-separated
     string; verified that an array breaks every later call)
   - Authentication: **Body**
3. Select this credential on every `ZENDESK:` node (HTTP Request, Authentication =
   Generic Credential Type, OAuth2 API).

Verified: the token endpoint and the `client_credentials` grant ("The token's user will
be the one associated with the client"); no refresh token is issued; tokens of clients
created after April 30, 2026 last 30 minutes by default.

**Assumed:**

- *Which user the token acts as.* The documentation does not say how a user is
  associated with a client; the conservative reading is the agent who created it.
  Check it: add a temporary HTTP Request node `GET {zendesk_base}/api/v2/users/me`
  with the credential, run it once, and copy `user.id` into
  `desk_integration_user_id`. That id is what matters, whoever it turns out to be.
- *Token request format.* Zendesk documents the token request parameters as JSON; the
  n8n credential sends the standard form encoding. If the credential cannot fetch a
  token, or a `ZENDESK:` node answers 401 with a fresh token, use the fallback below.
- *Renewal.* n8n is expected to fetch a new token when the old one expires, since the
  grant has no refresh token. Check an execution after the first 30 minutes.

**Fallback (API token, deprecated but still documented).** Admin Center, Apps and
integrations, APIs, Zendesk API: enable token access and add a token. In n8n create a
**Basic Auth** credential with user `<agent email>/token` and the token as password,
then on each `ZENDESK:` node switch Generic Auth Type from OAuth2 API to Basic Auth and
select it. That produces the documented `Authorization: Basic
base64(email/token:api_token)` header. An API token can act as anyone in the account,
so keep it in n8n only.

## 4. How customers become Zendesk users

Zendesk tickets need a requester id, an email, or a name with an email; never a
phone (verified). The contact lane therefore resolves a user id for every phone
number, in this order:

1. The workflow's own map (phone to user id, in n8n workflow data).
2. `GET /api/v2/users?external_id=spun:<+e164>`: exact, no search index. This finds
   users the workflow created earlier, also after a reset.
3. `GET /api/v2/users/search?query=phone:<+e164>`: adopts an end user your account
   already has with that phone. Search tokenises and lags by minutes, so the workflow
   accepts a hit only on an exact digit match, oldest user first. An adopted user is
   used as it is: the workflow never edits a customer record you already had.
4. `POST /api/v2/users/create_or_update` with `{external_id: "spun:<+e164>", name,
   phone, role: "end-user", skip_verify_email: true}`. It is keyed on that external
   id, so a retry updates instead of duplicating.

The workflow never sends an email address, so it never matches or changes a user by
email. Verified shapes; the phone match format in step 3 is **assumed** (the
documentation's examples mix formats, so step 2 and 4 never depend on it).

**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` and `identity_state: lid_unlinked` (`../README.md`, "WhatsApp privacy IDs
(LID)"). The digits are not a phone. For such a chat the lane above uses the external
id `spun-lid:<digits>` instead: step 1 checks the map key `lid:<digits>`, step 2 looks
up `external_id=spun-lid:<digits>` (exact, so no custom user field is needed), step 3
is skipped (there is no phone to search), and step 4 creates the user with that
external id, the name (or `privacy ID <digits>`) and no phone. When a later event of
the chat arrives with `identity_state: lid_linked`, the workflow adds the phone once
with `POST /api/v2/users/create_or_update` `{external_id: "spun-lid:<digits>", phone}`,
which updates the user that external id identifies. One attempt per privacy ID; a
failure is in the execution's `phone_added_error`.

## 5. Optional: group, brand, a "Spun line" and a "Spun LID" field

All optional. Leave a value on its `PASTE_OPTIONAL_...` placeholder (or empty) and the
workflow leaves it out of the ticket.

| Setting | Where to find it | Effect |
|---|---|---|
| `zendesk_group_id` | Admin Center, People, Groups (the id is in the URL), or `GET /api/v2/groups` | New WhatsApp tickets land in this group. |
| `zendesk_brand_id` | Admin Center, Account, Brand management, or `GET /api/v2/brands` | New tickets belong to this brand. |
| `zendesk_line_field_id` | Admin Center, Objects and rules, Tickets, Fields: add a **Text** field "Spun line" and copy its id | The Spun line (`integration_id`) that carried the chat is stored on the ticket. Useful with several WhatsApp lines. |
| `zendesk_lid_field_id` | Same place: add a **Text** field "Spun LID" and copy its id | The privacy ID of a privacy-ID chat (step 4) is stored on the ticket, so agents can see which chat it is. The user lookup does not need it (it uses the external id). |

`zendesk_tags` (default `["spun", "whatsapp"]`) are added to every new ticket. Keep
`spun`: the triggers and the fallback lookup rely on it.

## 6. The Zendesk webhook (agent replies to n8n)

1. Replace `PASTE-A-LONG-RANDOM-SUFFIX` in the **Helpdesk reply** node's path with a
   long random string, keeping the `zendesk-reply-` prefix. The router matches the
   prefix, so no other node changes.
2. Activate the workflow and copy the production URL of **Helpdesk reply**
   (`.../webhook/zendesk-reply-<your suffix>`).
3. Admin Center, Apps and integrations, Webhooks, **Create webhook**. Choose
   **Trigger or automation** as the way to connect. Endpoint URL: the URL from step 2.
   Request method **POST**, request format **JSON**, authentication **None**.
4. Create it, open its details page, **Reveal secret**, and paste the signing secret
   into `zendesk_webhook_secret`.

**Authenticity (verified).** Zendesk signs every delivery:
`X-Zendesk-Webhook-Signature` = base64(HMAC-SHA256(secret, timestamp + raw body)), with
the timestamp from `X-Zendesk-Webhook-Signature-Timestamp`, and the secret used as a
plain string. The **HMAC of reply body** node computes it, the adapter compares it in
constant time and drops anything that does not match. While `zendesk_webhook_secret`
is still a placeholder, everything is dropped.

The **Test webhook** button in Admin Center signs with Zendesk's static test secret, so
the workflow rejects those test calls (the execution shows "bad or missing
X-Zendesk-Webhook-Signature"). That is expected; test with a real comment instead.

**Delivery (verified).** Zendesk waits 12 seconds; the Helpdesk reply node answers 200
on receipt. Delivery is best effort: duplicates happen (the workflow dedupes on the
comment id) and, while Zendesk's circuit breaker is open, an event can be lost. The
workflow does not poll for missed replies.

## 7. The reply trigger

Admin Center, Objects and rules, Business rules, Triggers, **Add trigger**, name
"Spun: agent reply to WhatsApp".

Meet **all** of the following conditions:

| Condition | Operator | Value |
|---|---|---|
| Ticket | Is | Updated |
| Comment | Is | Public |
| Tags | Contains at least one of the following | `spun` |
| Update via | Is not | Web service (API) |

Action: **Notify by**, **Active webhook**, the webhook from step 6, body:

```json
{"kind": "comment", "ticket_id": "{{ticket.id}}", "external_id": "{{ticket.external_id}}", "updater_id": "{{current_user.id}}"}
```

The body carries ids only, because placeholders are not JSON-escaped (a quote or line
break in a reply would break a body that embedded the text). On each call the workflow
reads the five newest comments (`GET /api/v2/tickets/{id}/comments?sort=-created_at&
include=users`) and forwards the public ones written by the updater within
`reply_window_minutes` (default 15) whose author is an agent or admin.

The same trigger through the API, for reference:

```json
{"trigger": {"title": "Spun: agent reply to WhatsApp",
  "conditions": {"all": [
    {"field": "update_type", "value": "Change"},
    {"field": "comment_is_public", "value": true},
    {"field": "current_tags", "operator": "includes", "value": "spun"},
    {"field": "current_via_id", "operator": "is_not", "value": 5}
  ], "any": []},
  "actions": [{"field": "notification_webhook", "value": ["<webhook id>",
    "{\"kind\": \"comment\", \"ticket_id\": \"{{ticket.id}}\", \"external_id\": \"{{ticket.external_id}}\", \"updater_id\": \"{{current_user.id}}\"}"]}]}}
```

Verified: the four condition fields and their operators (`update_type` and
`comment_is_public` take no operator), via id 5 = Web service (API), the
`notification_webhook` action, and the placeholders. Agent replies in the agent
workspace are via 0, email replies via 4. **Assumed:** replies from the Zendesk mobile
apps use their own via ids (not 5) and therefore pass. An agent app or integration that
writes through the API (via 5) is excluded along with the workflow's own writes;
replies from such tools do not reach WhatsApp.

**Why not "Current user is not ..."?** The Admin Center offers it, but the API's
condition reference lists no such field. The workflow checks the author against
`desk_integration_user_id` itself instead.

### Default notification triggers (important)

Every inbound WhatsApp message is added as a public comment authored by the customer,
but the update is made by the integration agent. Zendesk's standard "notify requester"
triggers can treat that as an agent update and email the customer a copy of their own
message. Users the workflow creates have no email and receive nothing; a user you
already had (adopted in step 4) may have one. Add **Update via Is not Web service
(API)** (or **Tags Contains none of the following `spun`**) to every trigger that
emails the requester. The default trigger names and conditions differ between
accounts; this is **assumed** and is the first thing to check in the live run.

## 8. Desk-side close and reopen (optional)

When `close_in_spun_when_desk_closes` is true, solving a ticket in Zendesk archives the
conversation in Spun through `POST /api/integrations/conversations/close` (reason
`closed_in_zendesk`), and reopening it calls `/reopen` (reason `reopened_in_zendesk`).
Two more triggers on the same webhook drive it:

| Trigger | Conditions (all) | Body |
|---|---|---|
| Spun: ticket solved | Ticket Is Updated; Ticket status Changed to Solved; Tags contains `spun`; Update via Is not Web service (API) | `{"kind": "solved", "ticket_id": "{{ticket.id}}", "external_id": "{{ticket.external_id}}", "updater_id": "{{current_user.id}}"}` |
| Spun: ticket reopened | Ticket Is Updated; Ticket status Changed from Solved; Ticket status Is not Closed; Tags contains `spun`; Update via Is not Web service (API) | the same with `"kind": "reopened"` |

API condition fields: `{"field": "status", "operator": "value", "value": "solved"}` for
"changed to", `"operator": "value_previous"` for "changed from", `"operator": "is_not",
"value": "closed"`. Verified.

Requirements and caveats:

- The Spun API key needs the `manage_conversations` scope ("Close & Reopen
  Conversations" under Manage API Keys). A 403 from Spun means the key lacks it; a 404
  means no conversation matched.
- The workflow finds the conversation through its own ticket map. A ticket solved
  after the workflow's data was reset is not closed in Spun until a new message for it
  has passed through.
- A ticket that an automation closes (solved to closed) needs nothing: Spun was closed
  when it was solved.
- Spun to Zendesk always works: `conversation.closed` sets the ticket to **solved**
  (never closed), `conversation.reopened` sets it to **open**. If your account requires
  an assignee before solving, Zendesk answers 422; the ticket stays open in Zendesk and
  in the workflow's map, and the execution shows the error.

## 9. Spun side

1. Webhooks page, Manage API Keys: create a key with `send_message` (and
   `manage_conversations` for step 8). 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`.
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.

## 10. Settings node, all values

| Key | Value |
|---|---|
| `spun_api_base` | `https://api.spun.com/api/integrations` |
| `spun_webhook_secret` | the Spun subscription's signing secret (step 9) |
| `zendesk_base` | step 1 |
| `zendesk_webhook_secret` | step 6 |
| `zendesk_tags` | `["spun", "whatsapp"]`; keep `spun` |
| `zendesk_group_id`, `zendesk_brand_id`, `zendesk_line_field_id`, `zendesk_lid_field_id` | step 5, optional |
| `desk_integration_user_id` | steps 2 and 3 (`GET /api/v2/users/me`) |
| `reply_window_minutes` | `15`: how old an agent comment may be when its trigger call arrives |
| `skip_groups` | `true`. Groups and newsletters always end at "Skipped (group or newsletter)" (no single person to make a user for); privacy-ID chats are handled (step 4) |
| `inbound_prefix` | `[WhatsApp]` (also the echo filter) |
| `outbound_prefix` | `[Sent from Spun]` (private notes) |
| `desk_close_reason`, `desk_reopen_reason` | `closed_in_zendesk`, `reopened_in_zendesk` (loop guard for step 8) |
| `close_in_spun_when_desk_closes` | `false` until step 8 is set up and its first close is checked |
| `dedupe_hours` | `24` |

## 11. Message mapping

| Spun | Zendesk |
|---|---|
| First `message.inbound` of a conversation | `POST /api/v2/tickets`: subject "WhatsApp: " plus the first line (80 characters), first comment public and authored by the customer, requester and submitter = the customer, `external_id` = Spun chat id, tags, optional group, brand, line and Spun LID field. |
| Later `message.inbound` | `PUT /api/v2/tickets/{id}` with a public comment authored by the customer: `[WhatsApp] Name (+number): text` (`(privacy ID <digits>)` for a privacy-ID chat with no known phone). Sets the status back to open when it was pending, on-hold or solved. |
| Message after the ticket was closed | A new ticket created as a follow-up of the closed one (`via_followup_source_id`). |
| Media | The same comment with the file type, size and a link valid for 24 hours. Not uploaded as a Zendesk attachment. |
| `message.outbound` sent from the Spun inbox | Private comment `[Sent from Spun] Agent name: text`. |
| `conversation.closed` / `reopened` | Status solved / open. |
| Public agent comment in Zendesk | WhatsApp text message to the customer; HTML is converted to plain text; the comment id is the idempotency key. |
| Private comment, comment by the customer or by the integration agent | Not sent. |

Comment bodies are sent as `html_body` (escaped text, line breaks as `<br>`, links
clickable), because `body` collapses line breaks. Verified.

## 12. Zendesk API usage and limits (verified figures)

- A new conversation costs up to 5 requests (external id lookup, phone search,
  create user, ticket lookup, create ticket), one fewer for a privacy-ID chat with no
  known phone (no phone search); the one-time phone add of such a user is 1 more
  `create_or_update`; a follow-up message 1; an agent reply 2
  (comments read, plus a ticket read only when the workflow's map lost the ticket).
- **30 updates per ticket per 10 minutes per user.** All writes come from the
  integration agent, so a burst of more than 30 WhatsApp messages (or Spun replies) in
  one conversation within 10 minutes gets 429 on that ticket. The workflow does not
  combine or delay messages: a refused comment fails its n8n execution (visible in the
  execution list) and the message stays in Spun only. Re-run the execution in n8n once
  the 10 minutes have passed.
- **`create_or_update` user: 5 per minute per user.** More than five brand-new
  customers in one minute can get 429 on the user step; those executions fail and can
  be re-run.
- Account limit per minute: 200 (Team) to 700 (Enterprise), shared with your other
  integrations.
- A ticket holds at most 5,000 comments; the next append answers 422 and fails the
  execution. Close the conversation in Spun so the next message opens a new ticket.
- Archived tickets (closed for a long time) are not listed by
  `GET /api/v2/tickets?external_id=`, so after a reset the workflow starts a new ticket
  for such a conversation instead of a follow-up.

## 13. What was verified and what was assumed

| Item | Status |
|---|---|
| OAuth `client_credentials` grant at `/oauth/tokens`, no refresh token, 30-minute default on new clients | Verified (re-checked 10/07) |
| Token request accepted in form encoding (n8n default) | Assumed; API token fallback documented in step 3 |
| Which user a `client_credentials` token acts as | Assumed (creator); checked with `/users/me` in step 3 |
| n8n renews the token without a refresh token | Assumed |
| `Authorization: Bearer` with an OAuth token; Basic `email/token:api_token` | Verified |
| `GET /users?external_id=`, `GET /users/search?query=phone:`, `create_or_update` keyed on external id, 5 per minute | Verified; phone match format assumed |
| Privacy-ID users: `create_or_update` without `phone`, then once more with `{external_id: "spun-lid:<digits>", phone}` to add the phone | Built on the verified `create_or_update` (a user is identified by its external id); not live-tested |
| Ticket create with `comment.author_id`, `requester_id`, `submitter_id`, `external_id`, `tags`, `group_id`, `brand_id`, `custom_fields`, `via_followup_source_id` | Verified |
| `GET /tickets?external_id=` exact, not unique, archived tickets excluded | Verified |
| Comment via `PUT /tickets/{id}` with `comment.html_body`, `public`, `author_id` | Verified |
| `status: "open"` on update; closed is final; solve may need an assignee (422) | Verified |
| 30 updates per ticket per 10 minutes per user; 5,000 comments per ticket | Verified |
| Trigger fields `update_type`, `comment_is_public`, `current_tags`, `current_via_id` (via 5 = API, 0 = web form, 4 = email) | Verified (re-checked 10/07) |
| No `current_user` condition field in the API | Verified (re-checked 10/07) |
| Mobile app replies are not via 5 | Assumed |
| `notification_webhook` action, placeholders `{{ticket.id}}`, `{{ticket.external_id}}`, `{{current_user.id}}` | Verified |
| Placeholders are not JSON-escaped | Ambiguous; body carries ids only |
| List comments `sort=-created_at`, `include=users`, `page[size]` | Verified |
| Signature headers, base64 HMAC-SHA256 over timestamp + body, secret as plain string, "Reveal secret", static test secret | Verified (re-checked 10/07) |
| 12-second timeout, best-effort delivery, circuit breaker | Verified |
| Default notification triggers email the requester on agent-made updates | Assumed; see step 7 |
