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 are published in the GitBook API Reference and generated from the specification, which keeps it broadly in step with the API — broadly, not perfectly: the known drifts are flagged on this page. It deliberately does not duplicate the reference — two copies of a reference is one copy that goes stale.

Production and Beta now serve the same specification. Until late October they did not: Production served 27 paths (spec v3.2.3, last changed 2026-07-30) and Beta 42. On 2026-10-28 the openapi.yaml each environment serves was byte-identical, last modified on Production 2026-10-26 and on Beta 2026-10-19. Both serve 42 paths: the original 27, plus GET /v1/note-types, plus 14 Attachment V2 paths whose 17 operations are all still marked x-stability-level: alpha. So note types and note direction, webhook filters and OAuth callback auth are in Production's specification too. That is spec-read only: the hub has not probed Production, and a field being in the served spec is not proof the deployed code honours it. Where a claim on this page rests on a probe, it names the environment probed. The path counts, the sunset table, the enums, the note caps, the event types and the OpenAPI retry policy were re-checked against both served specs on 2026-10-28; the AsyncAPI retry policy was last checked 2026-10-20.

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.

A missing required field is still a 500, even with the header

The header covers rejections the platform raises itself. A request that leaves out a field the specification marks required is not validated against the specification at all, so it fails further in and comes back as 500 server-error in problem form. Examples: POST /v2/webhooks without callbackUrl, or POST /v1/collaboration-requests with an empty body. Validate required fields on your side before sending, and treat a 500 on a write you just built as "check your payload" before "the platform is down".

Source-read on both the Production and Beta branches; probed on Development

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.

Issuing Your Own Client Secret

Members now create the client secrets for their OAuth client themselves, in the TSANet webapp, instead of asking TSANet for one. TSANet still creates the client, meaning the app registration and its client ID, and still maps it to your member company. What moves to you is the secret, and with it the rotation.

Beta Development live now. Production scheduled for the week of 26 October 2026, not yet confirmed by the hub. Production's API specification changed on 2026-10-26, the start of that week, but the specification does not describe the webapp's Integration page, so it says nothing about this feature.

The page is open to users with the Program Manager or Business Manager role on the member company. Standard users and the API user can't reach it.

BehaviourWhat to expect
LifetimeOne year from creation. The period is fixed, and you can't choose it.
Several secretsEach create adds a secret alongside the ones you already have. Existing secrets keep working until they expire, which is what lets you rotate without downtime.
Registered Keys listShows each secret created on this page, by Secret ID, description and expiry date. It never shows a value.
RevokingThe page can't delete or revoke a secret. A secret you create stays valid until its expiry date. To end one early, for example after a leak, ask TSANet.
Secrets TSANet issued earlierThey keep working until their own expiry date, but they don't appear in Registered Keys. Ask TSANet for the date if you don't have it.

Rotate by overlapping, because you can't delete

Create the new secret, deploy it to every system that holds the old one, and confirm each with GET /v1/me. Then let the old secret lapse on its date. Start well ahead of the expiry: a month leaves room for a change freeze. A rotation done because a secret leaked isn't finished until TSANet has revoked the old one, since it otherwise stays valid for the rest of its year.

Production's page changes under you at the rollout

Until the rollout, Production's Integration page displays the secret TSANet issued you. After it, the page displays no secret values at all, only the Manage Secret Keys flow. If that page is the only place your current Production secret lives, put it in your secret store now: the rollout was scheduled for the week of 26 October.

Only for a client TSANet created

The webapp issues secrets on the app registration TSANet created for your company. If your company authenticates with an app registration in its own Entra tenant instead, you keep managing that registration's secrets in your own tenant, and the Integration page doesn't apply.

Read from the webapp's Beta branch source on 2026-10-24; working on Beta and Development per TSANet; not yet probed by the hub

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. Self-issued secrets don't change this: TSANet still sets up the client, and you issue its secrets.

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. Rotation is now self-service, but nothing rotates for you. When it lapses, every call stops at once, and it presents as a sudden total auth failure rather than a gradual degradation.

Every secret you issue shows its expiry date in Registered Keys; for a secret TSANet issued before self-issue, ask TSANet for the date. 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 use /v1/partners/{searchTerm} for lookup. Do not use /v1/partners/search: it does not currently work, returning 500 (probed on Beta; Production untested). Neither specification marks it deprecated yet; a deprecation is pending.

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.

Encrypted content is supposed to come back byte for byte

Requests and notes carry two optional fields, manifest (an opaque Compact JWS) and encrypted (boolean, default false), stored and returned untouched. The sanitizer behind the HTML above does not check the flag, so on create it rewrites " to " in an encrypted problemDescription and note text, which breaks the manifest's digests. Receivers have to decode before verifying until that changes.

Spec-read on Beta and Production; rewrite observed on Development. Details in End-to-end encryption

Note types and direction: in both specifications, probed on Beta only

GET /v1/note-types lists the types a note can carry (on Beta today partner, public, escalation and status; the specification lists none, and Production's set has not been read), and creating a note takes an optional type: {code}. Read notes carry type, companyId and companyName, plus a direction that is relative to you as the reader. Three rules from the specification: the set can grow without a release, so fetch it rather than hard-coding it; a missing or unknown type does not mean public; and the type is not an access-control filter. The specification also stops requiring priority on note create. Until 2026-10-26 this was Beta-only: Production's specification gave notes only companyName and required priority. Since then Production serves the same specification as Beta, so all of it is in Production's too. Until a Production call confirms it, treat a Production note without direction as possible and keep the fallback in TSANET-2026-001.

Spec-read on both environments, 2026-10-28 (Production's spec last modified 2026-10-26); not probed on Production. Full comparison in TSANET-2026-001

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. Each destination gets one delivery attempt with no automatic retry (source-read; the file-sharing topic has the detail), 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

HTTP 200 on a forward does not mean delivered

The forward call answers 200 whatever happened to the file. A forward that reached neither side still returns 200, with the failure in the body. Read completeSuccess and the per-side submitterStatus and receiverStatus, and never treat the status code as the outcome.

Source-read on both environments

Alpha: a direct-storage receiver refuses the relay

The receiver can register its own storage for Attachment V2. The file then goes straight from sender to storage under a short-lived grant and never passes through TSANet. When the receiver has done that, the v1 forward above can return 409 attachment-direct-path-required, and the sender has to use the V2 grant flow instead. V2 also adds an alpha listing of a case's grants, the first time the platform can enumerate attachment activity on a case. Until 2026-10-26 all of this was in Beta's specification only. Production's specification now carries it too, with every V2 operation still marked alpha, so its shape can change without a deprecation clock. Don't build a production flow on it yet, and handle the 409 wherever you forward attachments.

Spec-read on both environments, 2026-10-28; V2 exercised on Beta only (see Connect SDK), not probed on Production. The Java client's V2 support is described on Connect SDK

Where the reference and the platform disagree

The earlier drift is fixed: the GitBook reference now shows the forward call's description in the multipart/form-data body, required and capped at 500 characters, as the specification does. Two newer drifts ran the other way, with the reference ahead of what was deployed. The first still holds; the second has closed in the specification but is unprobed on Production.

  • Secret rotation. The reference documents POST /v1/webhooks/{id}/secret as a working rotation call, and the v3.2.1 changelog lists it as new. No implementation is behind it. Beta answers 501 with an empty body (probed). See Webhooks for what to do instead.
  • v2 webhook options. The reference's webhooks-v2 page shows caseDirections and OAUTH2_CLIENT_CREDENTIALS. On 2026-10-20 both were on Beta only. Since 2026-10-26 Production's specification carries both too (spec-read 2026-10-28), so this drift has closed at the specification level. Production has not been probed.

GitBook pages fetched 2026-10-20; compared with openapi.yaml as Production and Beta serve it, re-read 2026-10-28

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. The evidence now leans one way: the deployed delivery configuration (the same on Production and Beta) is 3 attempts an hour apart, and a Beta probe on 2026-10-19 saw 1-hour spacing, with a 4xx not retried. Both match AsyncAPI. Production has not been probed, and a correction to the OpenAPI text is pending but not merged. Until it lands, still don't design around a specific cadence: assume retries happen, make handlers idempotent, and treat the reconciliation poll as the real guarantee. An exhausted delivery carries no marker. Its final failed attempt is an ordinary success: false row 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, identically on Production and Beta. If you are starting now, start on the successors. Further clocks are under discussion but not decided, and the hub does not publish dates until they are in a served specification.

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

Outside alpha, the v2 surface is exactly three paths, the same in both environments' specifications: the two list endpoints and the webhook subscription. The rest of v2 is the alpha Attachment V2 paths (on Beta, and in Production's specification since 2026-10-26), which are new capability, not successors to anything on a clock. 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
Salesforcev2Since 2.14 (released 2026-10-21) it registers on /v2/webhooks with prefixed types and a V2 payload, and reads the v2 list and paged list. Its handler accepts both payload shapes and matches on substrings, so it cannot hit the no-op trap. It calls no deprecated path. Registration always sends OAUTH2_CLIENT_CREDENTIALS callbackAuth. Production's specification lacked it on 2026-10-24 and has carried it since 2026-10-26 (spec-read 2026-10-28), so Production registration should now be possible; it has not been probed. Until it is, the 15-minute sweep is what carries inbound on Production.Package source, TSANetConnect-2.14-Release
Connect SDK v3.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 and deliveries through v1 paths that are not deprecated and have no v2 equivalent. Secret rotation is not a capability, because the platform answers that call with 501. The generated v2 webhook client is unused. Only the deprecated half of this split can actually move.SDK source at v3.0.0, 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. Since v2.14.0.0, registration can ask the platform to authenticate deliveries with OAuth client credentials. The platform side of that is live on Beta and in Production's specification since 2026-10-26 (spec-read; not probed on Production).Solution source, release v2.14.0.0
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.