Hub / Connectors / Zendesk

Zendesk

A two-layer in-tenant connector: a ZAF app your agents work in, and a ZIS integration that receives inbound cases and forwards public replies. Both run inside your Zendesk account. This page is the architecture, the setup path, and the platform behaviour that costs real debugging time.

Class In-tenant (partial) Status GA Surface Ticket sidebar + nav bar Repo tsanetgit/Zendesk_App Updated 2026-09-13

Connector class

Zendesk sits between the two connector classes

TSANet connectors divide by one question: will the platform run your code inside the member's own tenant? Salesforce and Dynamics will, and ServiceNow would, though nothing is built there yet. Intercom will not, which is why that one is a hosted gateway. Zendesk is the interesting middle case — and understanding which half you are in explains most of the design below.

Everything in this connector runs in the member's account, so a broken install affects one member. The Connect API remains a shared dependency: a platform outage stops collaboration here as it does everywhere.
 Runs in-tenantConsequence
ZAF appYes — client-side, in a sandboxed iframeNo server, no storage, no native dialogs
ZIS flowsYes — Zendesk-hosted integration serviceDeclarative only; no arbitrary server code
CredentialsIn the member's own tenantThe member holds their TSANet credential
Lifecycle stateZendesk ticket fields + tagsNo external state store to operate
Arbitrary server codenoAnything ZAF and ZIS cannot express has no home
What this rules out. A background service you control does not exist here. Polling runs in a ZAF background page that lives only while a Zendesk tab is open, and inbound delivery arrives as a push into ZIS. If you need guaranteed server-side reconciliation independent of a browser session, that is the gateway pattern, not this one.

Architecture

Two layers, and one that no longer exists

Layer 1 is the ZAF app the agent sees. Layer 2 is ZIS, which holds the connections and receives inbound pushes. A third layer — a GitHub Actions workflow running a token-refresh job and an SLA monitor — has been retired: ZIS renews its own tokens now, and the ZAF background poller applies the SLA breach tag.

Agent workspace ticket sidebar panel Add Note · Accept · Reject · Close custom inline modals only panel measures, never asserts height Zendesk platform trigger: public comment + tsanet tag webhook (Basic auth) → ZIS custom fields · tagger dropdowns views · SLA breach trigger Support API /api/v2 tickets · comments · audits tag POST REPLACES the whole set: read → PUT concat + safe_update date fields are YYYY-MM-DD only the member's own Zendesk account · nothing hosted by TSANet LAYER 1 · ZAF app sandboxed cross-origin iframe all calls via client.request() domainWhitelist or CORS-blocked no localStorage, no prompt() sidebar + nav-bar deploy screen translations/en.json required LAYER 2 · ZIS integration name GLOBALLY unique job specs + flows (declarative) connection: tsanet_oauth (Entra) connection: zendesk (self, OAuth) API-only — invisible in Admin Center bundle upload needs admin session background.html poller 60s loop, ~50min JWT cache lives only while a tab is open inbound sweep + SLA breach tag updatedAfter incremental cursor ZIS inbound webhook generic inbound, Basic auth flow_handle_ping → create ticket flow_forward_comment → note author guard: Agent/Admin only State lives in Zendesk, not in a store we run TSANet Token (text, primary key) · TSANet Status (tagger) · TSANet Partner TSANet Respond By (date) · tsanet_inbound / tsanet_outbound tags note mirror dedups on a tsanet-note-id:<id> marker in the comment body field IDs are discovered by the app's Detect screen, never copied from URLs TSANet Connect API · /v1 partners · forms (documentId) collaboration-requests approval · rejection · closure information-request / -response notes (summary + description) /v2/…/list supports updatedAfter errors default to 500 — opt into problem+json via Accept prod: connect2.tsanet.org TSANet webhook push POST /v1/webhooks to subscribe callbackAuth: BASIC → ZIS ingest collaboration-request.created note.created v1 bare types · v2 breaks the bundle agent acts 1 2 3 4 5 ticket writes shares settings job specs
Everything inside the outlined box runs in the member's Zendesk account. Accent edges are calls that leave the browser or the tenant; plain edges are local. The green band is the only durable state, and it is Zendesk's, not ours.
Do not rebuild ZIS-based polling. A scheduled ZIS flow cannot call ZIS management endpoints — the OAuth scope is circular, and the automation payload carries no requestToken, so the flow fails at the first step. Inbound arrives by push; the sweep belongs to the ZAF background poller. This is settled, not an open problem to solve again.

Authentication

OAuth client credentials, and the scope that looks right but isn't

Server-to-server auth is an OAuth 2.0 client-credentials grant against Microsoft Entra. Unlike the legacy POST /v1/login JWT — which expires in about 60 minutes and has to be refreshed by something — the caller re-mints automatically from a long-lived client credential. That is what retired the token-refresh job on the ZIS side. The ZAF sidebar's own calls still cache a ~50-minute JWT (see the poller in the topology); which of the two schemes that token comes from has not been re-probed.

# Mint an Entra token
POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
  grant_type=client_credentials
  client_id={your TSANet-issued client id}
  client_secret={your TSANet-issued secret}
  scope={audience-guid}/.default      ← bare GUID
  scope=api://{audience-guid}/.default  ← AADSTS500011

Error responses have two modes, and the default is HTTP 500

This is permanent, documented behaviour rather than a bug to work around. With no Accept header — or application/json alone — most business-rule, validation and authorization rejections return HTTP 500 with a {"message": "..."} body, locked in for backward compatibility with existing clients.

# Recommended for every new integration
Accept: application/json, application/problem+json

That opts into RFC 7807: the same rejections return their documented status (400/401/403/404/409/422) with a {type, title, status, detail, instance} body. Branch on status code and read the message from detail, falling back to title. List application/json first — with application/problem+json alone, successful 200 responses also come back labelled Content-Type: application/problem+json, which confuses content-type-keyed consumers.

Lifecycle

Case states, and what the SLA actually measures

TSANet case lifecycle as seen by the Zendesk connector A case moves from Open to Information or Accepted, and then to Closed. Rejected is a terminal state drawn here from Information; the specification states no precondition for rejection, and the SDK skill's rule is that reject requires INFORMATION, so whether it is also reachable directly from Open is unverified. The SLA clock runs only while the case is Open and unresponded. OPEN responded: false INFORMATION clock stopped ACCEPTED clock stopped CLOSED submitter only REJECTED terminal info request approval closure rejection response SLA window respondBy tracks the initial acknowledgment only. Once responded is true, TSANet stops tracking — so must you.
The responded flag, not the status name, is the gate. false means the acknowledgment clock is running; true means the case has been accepted, rejected, or had information requested.

Note visibility

Three audiences, and a composer that only offers two

The governing rule is simple: only public content reaches the partner; internal notes stay in Zendesk. The complication is that a note has three possible audiences and Zendesk's native composer is binary — an app cannot add a third option to the built-in Public reply / Internal note toggle.

Internal the default Partner only ZAF dialog or field action Public reaches the end customer internal Zendesk comment never POSTed to TSANet ✕ partner never sees it POST /notes to the partner no public comment written mirrors back as internal agent keeps a record public Zendesk comment ONLY the app must not also POST trigger forwards to partner Agent/Admin authors only Single-path rule a public note must not ALSO POST /notes — the partner would receive it twice: the explicit POST, plus the trigger re-forwarding the same public comment.
Partner-only is reachable only through the ZAF Add Note dialog or the TSANet Action field — never through Zendesk's native reply menu, which will never show it. Agents have to be trained to use those.

The Add Note payload

summary is required and capped at 500 characters; description is optional and capped at 5,000. The TSANet web UI renders both as separate labelled sections, so posting identical values shows the text twice — it looks like a duplication bug and is intentional rendering. Present two fields, Subject and Details, and only include description if the agent filled it in. Never auto-copy one into the other.

ZAF constraints

What the sandbox takes away

ZAF apps run in cross-origin sandboxed iframes inside Zendesk. Three consequences drive most of the app's shape.

  • prompt() and confirm() are silently blocked. Not an error — the call simply does nothing, which presents as a button that does nothing when clicked. Every modal must be custom inline HTML.
  • Every API call goes through client.request(). Direct fetch() or XMLHttpRequest to an external domain is CORS-blocked unless that domain is listed in manifest.jsondomainWhitelist.
  • No localStorage, no sessionStorage. State lives in memory for the session, or in Zendesk ticket fields for anything durable.
  • client.set() does not write. It queues a change in the ZAF form state, applied when the agent saves the ticket. For a guaranteed write with no agent action, call PUT /api/v2/tickets/{id}.json through client.request().
  • There is no manifest property that makes a panel fit its content. flexible governs width on the ticket sidebar; flexible_height is not a ZAF property at all and is silently ignored. Height comes only from client.invoke('resize').
  • Measure, never assert. Every panel state should measure document.body.scrollHeight after content is in the DOM — measuring while the panel is still a spinner is what makes hardcoded constants look necessary.
  • One height cap for every state. Measured at the 320px sidebar width across 110 live partner forms: 41% need more than 800px and the tallest is 1409px, so an 800px cap truncates most of the member base the moment one label wraps.
  • translations/en.json is mandatory in the ZIP even with no i18n strings — Zendesk rejects the upload without it. A 128×128 transparent logo.png plus an icon manifest entry is what puts the app in the tray.
Deploy by ZIP upload, not by API. PUT /api/v2/apps/{id}.json is broken — it returns a Ruby "no implicit conversion of nil into String" error. Build the ZIP and upload through Admin Center → Apps and Integrations → Zendesk Support Apps → Update. Settings, credentials and field IDs survive the update.

Setup

The path through installation

Custom ticket fields

FieldType
TSANet TokenText — primary key
TSANet Tokens MultiText — optional
TSANet StatusDropdown (tagger)
TSANet PartnerText
TSANet Respond ByDate

Do not copy field IDs out of Admin Center URLs. Create the fields, then open the app from the left nav bar and use Detect field IDsApply. It matches by title, shows the mapping before writing, and writes the IDs into its own settings. It refuses to guess when two fields share a title or a title match has the wrong type — fix the field and re-run.

Order of operations

  • Get API credentials and service-principal provisioning from TSANet
  • Create the five custom ticket fields
  • Register a ZIS integration under your own name (see below)
  • Create the tsanet_oauth (Entra) and zendesk (self) connections — then GET the access_codes URL returned in each response's redirect_url, with the ZIS bearer, to complete creation; the step is required even for client credentials
  • Substitute your instance's connection name into all five action_ts_* actions before deploying the bundle: an incomplete substitution leaves ingest returning 200 while the TSANet calls silently no-op
  • Deploy the ZIS bundle from the app's nav-bar screen — no API token needed
  • Run Detect field IDs → Apply, then redeploy the bundle: the upload strips the field actions while the field IDs are unset, so a bundle deployed before this step has none
  • Decide on acceptance: the Auto-accept inbound requests app setting (v1.0.69+, off by default, unconditional, takes effect after a bundle redeploy; engineerEmail falls back to the TSANet API username when the engineer-email setting is blank, and a failed in-flow accept leaves a manual-accept-needed private comment with the case still OPEN and unresponded), or a Zendesk trigger on the TSANet Action field for anything conditional — see deciding what to accept automatically
  • Register inbound push: two calls, Zendesk then TSANet
  • Create the SLA breach trigger and the active-collaborations view

The ZIS integration name is globally unique across all of Zendesk

This is documented vendor behaviour, and it is the single most expensive thing on this page to learn at runtime. Register a name and no other Zendesk customer can ever use it. tsanet_connect is already claimed, so every member needs their own name — the app exposes it as the tsanet_integration_name setting (charset ^[a-z0-9_-]{1,64}$).

POST /api/services/zis/registry/{your_integration_name}
400 {"message":"the integration: tsanet_connect is not available for upsert by this account"}
# Not a permissions fault. Not retryable. Pick your own name.

Inbound push registration is two calls, and the version matters

# 1. On Zendesk — returns ingest URL + Basic credentials, shown once
POST /api/services/zis/inbound_webhooks/generic/{integration}

# 2. On TSANet — subscribe TSANet to that URL
POST /v1/webhooks
{ "callbackUrl": "<ingest URL>",
  "callbackAuth": { "type": "BASIC", "username": "...", "password": "..." } }

Omit eventTypes and the subscription covers what the bundle expects — collaboration-request.created and note.created. Those are the only two event types v1 delivers — the specification's own v1 tag says so — so omitting eventTypes cannot subscribe the bundle to more; the three v2-only types never reach a v1 subscription. Save the response id for later management and the secret (the HMAC key, returned only at creation).

callbackAuth arrived in Connect API v3.1.0 and was validated on Beta. Since app v1.0.69 the inbound case's own customFields are surfaced too, appended to the created ticket as a "Partner form fields" block; they arrive unordered, so the bundle sorts them by displayOrder.

/v2/webhooks silently breaks the bundle. Both endpoints accept a subscription, so this fails invisibly. /v2 delivers CloudEvents with prefixed type strings (org.tsanet.connect.collaboration-request.created), while the bundle's guard matches the bare collaboration-request.created and falls through to a no-op. Every delivery returns 200 and creates nothing, with no error anywhere. Use /v1 until the CloudEvents migration ships.

Platform gotchas

Things that cost real debugging time

Zendesk side

Tag POST is not additive, whatever its name says. Zendesk documents POST /api/v2/tickets/{id}/tags.json as "Add Tags" and it replaces the ticket's entire tag set. Because tagger dropdown fields are stored as tags, replacing the tags also blanks those fields — which is how a status field goes empty on a breached ticket. additional_tags is not an escape hatch: on the single-ticket update endpoint it returns 200 and writes nothing, with no audit event.
# The pattern that works
GET  /api/v2/tickets/{id}.json
PUT  /api/v2/tickets/{id}.json
{ "ticket": { "tags": [...existing, "new_tag"] },
  "safe_update": true,
  "updated_stamp": "<ticket.updated_at>" }
# A stale stamp returns 409 UpdateConflict and writes nothing —
# a concurrent edit fails loudly instead of being clobbered.

TSANet side

Deprecation clocks

Dates worth putting in your calendar

Two independent retirement schedules affect this connector. Neither is a TSANet decision, and both have hard dates.

DateWhat happensImpact
2026-01-12Zendesk removed password access from remaining accountsAlready in effect — the bundle-upload path cannot fall back to a password
2026-07-28Zendesk blocks API token creation for new accountsNew installs cannot mint an API token — use the app's deploy screen, which runs on the admin session
2026-10-27Zendesk blocks API token creation for all accountsAny runbook step that says "create an API token" stops working
2027-01-01x-sunset on TSANet /v1/webhooksThe CloudEvents migration to /v2 must ship before this
2027-04-30All existing Zendesk API tokens deactivateAny connection still using basic_auth with an API token breaks
Bundle upload is the one endpoint that rejects OAuth outright. POST /registry/{integration}/bundles returns 401 for all OAuth bearers. It accepts an API token or an authenticated admin session — and since API tokens are on the clock above, the admin-session path is the documented one. That is exactly what the app's nav-bar deploy screen uses, so no member should be minting a token for this.

Build with an assistant

The implementation skill, and how to load it

The connector repository ships an agent skill — SKILL_TSANet_Connect.md — distilled from the production implementation: the two-layer architecture, the OAuth setup and its traps, the custom-field scheme, the lifecycle rules, and the platform gotchas on this page in a form an agentic coding assistant can apply while it works in your codebase. It is a plain markdown file with skill frontmatter (name, description and a trigger key), so it loads into Claude Code and any other assistant that accepts an instruction file. It is not quite self-contained: its PII-retention section points at a sibling file, PII_Retention_and_Data_Handling.md, in the same repository, so fetch that alongside it. Every skill TSANet ships, and how to load one into any assistant, is collected on Agentic usage with TSANet Connect.

Loading it

Claude Code — put it in your integration project's skills directory; from then on it triggers automatically whenever the work touches TSANet, ZAF or ZIS:

mkdir -p .claude/skills/tsanet-connect
curl -o .claude/skills/tsanet-connect/SKILL.md \
  https://raw.githubusercontent.com/tsanetgit/Zendesk_App/main/SKILL_TSANet_Connect.md

Other assistants — add the same file to whatever your tool treats as project instructions (a rules file, a context attachment). Nothing in it is Claude-specific. The skill lives in the connector repository and evolves with it, so re-download it when you upgrade the connector.

Typical first steps

Two boundaries worth keeping. Never paste client secrets, tokens, or webhook signing keys into an assistant session — the skill never needs a real credential to do its job. And the skill is distilled guidance, not a substitute for this page or the repository's install documentation: where they seem to disagree, the repository is newer and this page says what it was verified against.