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.
Connector class
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.
| Runs in-tenant | Consequence | |
|---|---|---|
| ZAF app | Yes — client-side, in a sandboxed iframe | No server, no storage, no native dialogs |
| ZIS flows | Yes — Zendesk-hosted integration service | Declarative only; no arbitrary server code |
| Credentials | In the member's own tenant | The member holds their TSANet credential |
| Lifecycle state | Zendesk ticket fields + tags | No external state store to operate |
| Arbitrary server code | no | Anything ZAF and ZIS cannot express has no home |
Architecture
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.
client.request() — the ZAF proxy. Direct fetch() to an external host is CORS-blocked unless the domain is in manifest.json → domainWhitelist.tsanet_inbound or tsanet_outbound, posts to a Basic-auth webhook, and ZIS forwards it to the partner as a note. Internal comments never fire it, which is what keeps the note mirror loop-safe.callbackAuth credentials you registered. flow_handle_ping creates the Zendesk ticket.action_ts_* actions call the Connect API through the tsanet_oauth connection, which mints and renews Entra tokens on its own.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
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
api:// prefix fails with AADSTS500011 — "resource principal not found" — because the Connect app registration publishes no Application ID URI. This looks like a typo and is not; every working scope is bare-GUID form.requestedAccessTokenVersion change is needed. The API accepts the default v1-format Entra token with a bare-GUID aud claim.. has been seen in production. "Cleaning" the value breaks authentication with AADSTS7000215. If the same secret works directly but fails through ZIS, the stored copy is corrupted: re-PATCH it and watch for paste artifacts.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
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.responded === false. Showing a countdown on an ACCEPTED case is showing a deadline TSANet is no longer tracking./closure as the receiver returns an error, so show the Close action only when direction === 'OUTBOUND'.token is the primary key — save it the moment a case is created. The numeric id exists but every API path uses token.PENDINGACTION is an additional status the /v2 list endpoints return and filter on; it surfaces cases awaiting an action from your side.YYYY-MM-DD only. An ISO datetime like 2026-05-08T14:00:00Z is silently rejected — truncate respondBy with .substring(0, 10).Note visibility
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.
{{current_user.role}} renders the literal Admin, not Administrator, and ZIS Choice supports only StringEquals — so list every accepted value explicitly, lowercase variants included.tsanet-note-id:<id> in the body. The native field-action path writes a receipt comment with the same marker, so there is exactly one internal record whether or not ZAF is installed.summary and description to plain text before display. The process form's adminNote is the exception — see below.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
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.client.request(). Direct fetch() or XMLHttpRequest to an external domain is CORS-blocked unless that domain is listed in manifest.json → domainWhitelist.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().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').document.body.scrollHeight after content is in the DOM — measuring while the panel is still a spinner is what makes hardcoded constants look necessary.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.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
| Field | Type |
|---|---|
| TSANet Token | Text — primary key |
| TSANet Tokens Multi | Text — optional |
| TSANet Status | Dropdown (tagger) |
| TSANet Partner | Text |
| TSANet Respond By | Date |
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 IDs → Apply. 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.
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 credentialsaction_ts_* actions before deploying the bundle: an incomplete substitution leaves ingest returning 200 while the TSANet calls silently no-opengineerEmail 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 automaticallyThis 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.
zendesk_oauth_client object with identifier zis_<integration-name>; its numeric id is what mints ZIS tokens. A hand-created client is refused on every ZIS management endpoint with 401 Authorization failed due to integration mismatch.# 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
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.
GET /api/v2/tickets/{id}/audits.json, find events with field_name: tags, and previous_value holds the replaced list.current_tags, not tags, for tag conditions, and assignee_id, not assignee, for recipients. Wrong values return "Invalid rule target" — or fail silently.execution.columns. Add them by hand in Admin Center after creating the view.PUT /api/v2/tickets/{id}.json with ticket.comment.public: false. The Comments endpoint does not support the internal flag the same way.401 integration mismatch means the credential is valid but was minted from the wrong OAuth client, or the integration in the path is not registered. 401 Authentication failed means the bearer is missing, malformed or expired. 403 API token is not supported means an API token was sent where a ZIS OAuth token is required.allowed_action_roles setting is defence in depth for who can invoke actions, not a way to hide anything. Neither is a security boundary — the TSANet API credential is the real control.engineerEmail is required on approval, and its domain rule is undocumented. The specification marks the field required — omitting it still returns "Error processing request" with no useful detail — but nothing documents that the address must be from your company's TSANet-registered domain. An agent's own Zendesk email fails domain validation, so use the dedicated API user's address.customFields[].options separates choices with CRLF, and the structured selections[] array is frequently empty. Splitting on commas collapses every choice into one option. Parse selections[].value first, then split on newlines, then fall back to commas.adminNote is authored HTML to render, not strip. The form's "Partner instructions" field carries formatted text and links the partner wrote. Escaping shows raw tags; stripping loses the links. Sanitize against a tag allowlist and render, forcing links to target="_blank" rel="noopener noreferrer" with http(s):-only hrefs. This is the one field that is the exception to stripping HTML.documentId is required on submission and must be fetched fresh from the form endpoint every time. Vendors update their forms; do not cache it long-term.departmentId for precise routing when it is available./v1/cases endpoint. It is a common wrong guess that returns 404. The list endpoints are /v2/collaboration-requests (paginated, returns {content:[...]}) and /v2/collaboration-requests/list (plain array, supports updatedAfter).testSubmission: true submits without creating real SLA timers or partner notifications.Deprecation clocks
Two independent retirement schedules affect this connector. Neither is a TSANet decision, and both have hard dates.
| Date | What happens | Impact |
|---|---|---|
| 2026-01-12 | Zendesk removed password access from remaining accounts | Already in effect — the bundle-upload path cannot fall back to a password |
| 2026-07-28 | Zendesk blocks API token creation for new accounts | New installs cannot mint an API token — use the app's deploy screen, which runs on the admin session |
| 2026-10-27 | Zendesk blocks API token creation for all accounts | Any runbook step that says "create an API token" stops working |
| 2027-01-01 | x-sunset on TSANet /v1/webhooks | The CloudEvents migration to /v2 must ship before this |
| 2027-04-30 | All existing Zendesk API tokens deactivate | Any connection still using basic_auth with an API token breaks |
zendesk connection is now OAuth, not basic auth. It runs a client-credentials grant against the member's own instance, with token_url pointed at https://{subdomain}.zendesk.com/oauth/tokens. Tokens from clients created on or after 2026-04-30 expire in 30 minutes, so a static bearer connection is not viable — only the auto-renewing OAuth type works.kind: "confidential" at creation. The grant rejects public clients with unauthorized_client, and changing kind afterwards regenerates the secret while only ever displaying it truncated.read tickets:write. tickets:read alone breaks search — /api/v2/search.json returns 403 under it, and there is no search:read scope.basic_auth connection named zendesk and create the OAuth one under the same name. Zero bundle changes.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 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.
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.
AADSTS code or a silent no-op delivery means more to the skill than a paraphrase — several of this platform's failures look unrelated to their cause.