Hub / API

Working with the Connect API

Not an endpoint reference — that already exists and is generated from the specification. This is the layer underneath: the behaviours that cost other integrators real time, gathered from building four connectors and a Java client against this API.

Base /v1 and /v2 Auth OAuth 2.0 client credentials Prod connect2.tsanet.org Beta connect2.tsanet.net

Looking for the endpoint reference?

Full request and response schemas, parameters and status codes for all 27 paths are published in the GitBook API Reference and generated from the specification, which keeps it broadly in step with the API — broadly, not perfectly: one known drift is flagged on this page. The path count, the sunset table, the status and attachment enums, the note caps, the event types and both retry policies were re-checked against openapi.yaml and webhook-asyncapi.yaml on 2026-09-13. It deliberately does not duplicate the reference — two copies of a reference is one copy that goes stale.

Error modes

The One Header That Changes How Every Failure Reaches You

If you read nothing else here, read this. By default, most business-rule, validation and authorization rejections come back as HTTP 500 with a {"message": "..."} body. Not 400, not 422 — 500. An integration written against the defaults cannot distinguish "you sent a bad email domain" from "the platform is broken", and will retry things it should not.

This is deliberate and permanent, locked in for backward compatibility with clients built before the alternative existed. The fix is one header:

Accept: application/json, application/problem+json
GET /collaboration- requests/{unknown} default Accept problem+json 500 Internal Server Error {"message": "Case with token ... not found."} 404 Not Found {type, title, status, detail, instance} Indistinguishable from a real outage. Retries make it worse. Branch on status. Render detail next to the field that fixes it.
Same request, same rejection, two different worlds. With the header you get the documented status code (400 / 401 / 403 / 404 / 409 / 422) and an RFC 7807 body — 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 breaks consumers that switch on content type.

Authentication

Two Schemes, One of Them Legacy

SchemeLifetimeNotes
OAuth 2.0 client credentials~60 min, re-minted automaticallyThe current server-to-server path. No static token to refresh, because the caller re-mints from a long-lived credential.
POST /v1/login~60 min JWTLegacy. Something in your system has to hold and refresh the token, which is the problem the OAuth path removes.

The scope that looks right and isn't

Use the bare audience GUID plus /.default. The api:// prefix fails with AADSTS500011 because the app registration publishes no Application ID URI. This reads like a typo in the working version, and it is not.

Worked through on Zendesk → Authentication

Provisioning is a prerequisite, not a formality

An app-only token is accepted only once TSANet has provisioned your service principal's object ID — that provisioning is also what maps your tokens to your member company. Ask before you start building, not when auth fails.

Store secrets verbatim

Client secrets are random strings that can begin with punctuation. Trimming or "cleaning" the value breaks authentication with AADSTS7000215.

Tokens renew themselves; the credential behind them does not

It is easy to read "no static token to refresh" as "nothing to maintain". The token re-mints automatically — the client secret it re-mints from has its own expiry, and rotating it before that date is the member's responsibility. When it lapses, every call stops at once, and it presents as a sudden total auth failure rather than a gradual degradation.

Confirm the expiry date and the rotation process with TSANet at onboarding, put the date somewhere that will actually alert you, and rehearse the rotation before you need it. This is the single most predictable outage available to an otherwise healthy integration.

Always call GET /v1/me during development to confirm which company your credentials actually resolve to. On a platform where every call is scoped to your member identity, that is the cheapest sanity check available.

Partners & forms

The Process Form Is Not Static, and Neither Are Its Field Types

Fetch the form fresh, every time

Partners change their own forms. The documentId you must submit with a case comes from that fetch, so a cached form means a stale documentId and a rejected submission. Cache it for the duration of one submission flow, never longer.

SELECT options are newline-delimited

The options string on a custom field separates choices with CRLF, not commas, and the structured selections[] array is frequently empty. Splitting on commas collapses every choice into one option — a bug that renders perfectly and is wrong. Prefer selections[].value, fall back to splitting on newlines, then commas.

Worked through on Zendesk → Platform gotchas

adminNote is HTML to render, not to strip

The form's "Partner instructions" field is authored HTML with links. Escaping shows raw tags; stripping loses the links. Sanitize against a tag allowlist and render it. This is the one field that is the exception to stripping HTML.

Search returns an array: a company with several departments returns several results. Use departmentId for precise routing when it is available, and remember that /v1/partners/search takes a natural-language query if plain matching is not enough.

Collaboration requests

The Lifecycle, and the Four Rules That Catch People

What you send is not what you read back

The write model and the read model use different names for the same fields. You POST problemSummary and problemDescription; you GET summary and description. Your own case number goes out as internalCaseNumber and comes back as submitterCaseNumber or receiverCaseNumber depending on which side you are. Contact details go out under submitterContactDetails and come back flattened onto the case.

Nothing is wrong when this happens — it is one case with two schemas. But a mapping layer written from the POST body alone will silently fail to read anything back.

Verified against CollaborationRequestDTO (write) and CollaborationRequestStatusDTO (read) — see TSANET-2026-001

Save the token immediately — it is the primary key

A numeric id exists, but every path uses token. Persist it durably at the moment of creation, before anything else can fail. An integration that creates a case and then loses the token has orphaned it: the partner has a case nobody on your side can find.

There is no idempotency key on create

A second POST with the same internalCaseNumber creates a second case. Since a timeout or a 5xx leaves you unable to tell whether the first one landed, a naive retry is how partners receive duplicate requests. Treat ambiguous outcomes as ambiguous: park the flow, reconcile by lookup, and only then re-arm the retry.

The full outcome taxonomy is on Fin / Intercom → Submit outcomes

responded gates the SLA, not the status name

The SLA tracks the initial acknowledgment deadline only. Once responded is true, the platform stops tracking — so a countdown shown on an ACCEPTED case is a deadline nobody is measuring. Gate every SLA display and breach check on responded === false.

Only the submitting company can close

Calling /closure as the receiver returns an error. Show the action only on outbound cases. And note the asymmetry with acceptance: a case cannot be closed while still OPEN by either party, so every case that ends cleanly must pass through acceptance first.

Probed and diagrammed on Fin / Intercom → lifecycle

Cases auto-close after 30 days of inactivity

The one closure that does not come from the submitter. Reporting that assumes a manual close is wrong on every connector, and an integration that mirrors status should expect a CLOSED it never sent. It sits in tension with the probed rule that an OPEN case cannot be closed by either party; whether the inactivity close applies to cases still OPEN is unverified.

Documented on the Salesforce and Dynamics connector pages — not probed here

Statuses

The enum is OPEN, INFORMATION, ACCEPTED, REJECTED, CLOSED, PENDINGACTION — plus ALL as a list filter. PENDINGACTION surfaces cases awaiting an action from your side; it is thinly documented, so treat an unrecognised value as unknown rather than guessing. A stale status is visibly wrong; a guessed one is invisibly wrong.

Approval is a commitment, not a routing step

Accepting tells the partner an engineer is engaged, and it satisfies the SLA at the same moment. Those pull in opposite directions, which is why automating acceptance is a real decision rather than a convenience: a rule that accepts something nobody will work stops the clock and leaves the partner waiting — worse for them than an honest breach, because a breach is visible.

Four shipping postures compared in Deciding what to accept automatically

Approval's email carries an undocumented constraint

engineerEmail is required on /approval — the specification marks it required, though omitting it still returns an unhelpful error. What the specification does not say: the address must be on your company's TSANet-registered domain. An individual agent's own address fails domain validation, so use the dedicated API user's address.

Listing and polling

Notes & attachments

Two Fields, Rendered Twice

Never copy summary into description

summary is required and capped at 500 characters; description is optional and capped at 5,000. The web UI renders both as separate labelled sections, so posting identical values displays the text twice. It looks like a duplication bug and it is intentional rendering. Present two fields — Subject and Details — and send description only when it has content.

Note content comes back as HTML

Both summary and description return with markup. Strip to plain text before display in a non-HTML surface. The process form's adminNote is the exception — that one renders.

Attachments are a two-party setup, and the platform stores nothing

A forward is delivered straight to the other party's registered endpoint — there is no attachment store, and no endpoint that lists files on a case. Delivery also fans out to your own registered endpoint, so an integration that only ever sends still wants one: without it every forward reports submitterStatus: CONFIG_MISSING and you keep no copy of what you sent.

Configuration is per case and readable before you act: GET …/attachments/config returns the submitter and receiver configurations, POST …/attachments/config/https/analyze proposes a normalized configuration without saving it, and PUT …/attachments/config/https registers one. Check it before offering an attachment action rather than discovering CONFIG_MISSING afterwards. Delivery attempts are not specified here: the file-sharing topic documents one attempt per destination with no automatic retry, which is documented rather than probed — so build the receiver so that a 2xx means durably stored.

Two things the platform will not do for you: it creates no case note announcing the file, and it keeps no history. Both are yours to build.

Full model in TSANET-2026-002

The reference shows description in the wrong place

On the forward call, the GitBook reference currently shows description as a query parameter. The specification puts it in the multipart/form-data body — a required field, capped at 500 characters, alongside files. A request built from the reference omits a required form field. Send it in the form.

Verified against openapi.yamlPOST /v1/collaboration-requests/{token}/attachments, requestBody

Note-visibility modelling across three audiences: Zendesk → Note visibility

Webhooks

Two Versions That Both Accept Your Subscription

This is the trap that fails silently. Both /v1/webhooks and /v2/webhooks accept a subscription. They deliver different event type strings — v1 sends bare names like collaboration-request.created, v2 sends CloudEvents with org.tsanet.connect. prefixes. A consumer written for one and subscribed to the other returns 200 to every delivery and does nothing at all, with no error anywhere to find.
VersionEvent type formatStatus
/v1/webhookscollaboration-request.createddeprecated · sunset 2027-01-01
/v2/webhooksorg.tsanet.connect.collaboration-request.createdcurrent · CloudEvents

The five event types

These are the v2 set. A v1 subscription delivers only two of them — collaboration-request.created and note.created — so closures and responses reach a v1 consumer through the poll, never the push.

org.tsanet.connect.collaboration-request.created
org.tsanet.connect.collaboration-request.closed
org.tsanet.connect.collaboration-request.response.created
org.tsanet.connect.collaboration-request.response.updated
org.tsanet.connect.collaboration-request.note.created

Payloads are thin — the fetch is what establishes truth

An event names a token and little else. Do not try to reconstruct state from the event body: acknowledge fast, then fetch the case and act on what you read. This also makes redelivery harmless, provided your writes are keyed on the token.

Delivery is at-least-once, so build for duplicates

Your endpoint may receive the same event more than once, and concurrent delivery workers mean events can arrive out of order. Deduplicate on the CloudEvent id (v2), or on requestToken + eventType + timestamp (v1). The pull-on-notify pattern is naturally tolerant of both, which is another reason to fetch rather than trust the payload.

4xx is permanent — it is not a "try me later"

A 4xx response is treated as a permanent failure and never retried; only 5xx responses and network timeouts trigger redelivery. So if you reject a payload you cannot route, returning 400 discards the event for good. Acknowledge with a 200 and handle the problem on your side, then let reconciliation catch anything genuinely lost.

Recovery design on Fin / Intercom → Recovery

You have 30 seconds to respond

Past that the delivery is treated as failed. Acknowledge immediately and do the real work asynchronously — every connector on this hub fast-acks and hands off to a queue for exactly this reason.

The published retry policy is currently inconsistent. The OpenAPI specification states 5 total attempts with exponential backoff from 10s capped at 300s; the AsyncAPI specification states 2 retries an hour apart. Both are published, and they disagree. Until it is reconciled, do not design around a specific cadence — assume retries happen, make handlers idempotent, and treat the reconciliation poll as the real guarantee. Exhausted deliveries are dead-lettered either way and flagged in the delivery log.

Managing a subscription

Deprecation clocks

What Is on a Timer

Three paths carry deprecated: true and an explicit x-sunset in the specification. If you are starting now, start on the successors.

PathSunsetUse instead
GET /v1/collaboration-requests2027-01-01/v2/collaboration-requests (paginated) or /v2/collaboration-requests/list
GET /v1/webhooks2027-01-01/v2/webhooks
POST /v1/webhooks2027-01-01/v2/webhooks

The migration is smaller than "v1 → v2" sounds

The v2 surface is exactly three paths: the two list endpoints and the webhook subscription. Creating a case and every {token} sub-resource — approval, rejection, closure, notes, attachments, the information exchange — exist only on v1 and carry no deprecation. Migrating means moving your list polling and your webhook subscription; nothing else moves. And the webhook half is a two-sided change: the subscription and the consumer's event-type matching have to move together, or every delivery returns 200 into a no-op.

Where the shipped integrations stand

IntegrationPostureWhere it standsEvidence
Fin / Intercomv2Consumes CloudEvents with prefixed type strings end to end.Connector source — TsanetWebhookController, v2 only
Zendeskv1 · deliberateThe ZIS flow matches bare type strings, so a v2 subscription would deliver into a no-op. The CloudEvents migration is tracked and ships with a future app release; until then v1 is the correct choice for this connector.Connector source and setup guide
Salesforcev1The Apex REST endpoint receives the v1 flat payload.Documented — not re-probed
Connect SDK v2.0.0splitList reads already call /v2/collaboration-requests/list, but the whole webhook surface is still v1 — subscribe and list through the generated v1 client, and delete, secret rotation and deliveries through v1 paths that are not deprecated and have no v2 equivalent. The generated v2 webhook client is unused. Only the deprecated half of this split can actually move.SDK source, generated client and openapi.yaml
Dynamicsv2Registers on /v2/webhooks with prefixed types, for creation and note events only — responses and closures ride the poll. The endpoint flow never matches type strings (it reads the token and fetches), so it cannot hit the no-op trap. An orphaned v1 registration flow still ships.Solution source, release v2.13.0.1
New work should be on v2 for both lists and webhooks. The v1 postures above are migrations in progress, not recommendations — and the Zendesk row is the live illustration of why the two sides of a webhook migration cannot move independently.