Skip to content

Both ways: syncing tickets

Two-way sync is the one integration that is not just plumbing. The endpoints are easy; the question that decides whether it works is which side owns the record. Answer that first, on paper, then the code is short.

Decide ownership first

Pick one of these three and write it down. There is no fourth option that avoids the choice.

PatternWho may change whatWhen it fits
Your system ownsTickets are created and progressed in your FM system. We hold a mirror; our UI is read-mostly for those tickets.Your dispatchers keep working where they already work; Tango Vision is the map and the data showcase.
We ownTickets are created and progressed here (with the location, the plan, the equipment, the SLA). Your system receives a copy for reporting.The building team lives in Building OS; your system is the archive of record.
Split by lifecycleYou own everything up to triaged; we own execution from in_progress onward; you own closure.Two teams with a real handover — the handover is the ownership boundary.

Whichever you choose, the rule that keeps it sane is the same: for each field, exactly one system may originate a change. Everything else is a mirror, and a mirror never writes back the value it just received.

The endpoints

Served from the Building OS origin, e.g. https://building-os.k8s.tangovision.dev/api/service-desk/…

ActionCall
CreatePOST /api/service-desk/requests
List / pollGET /api/service-desk/requests?buildingId={uuid}
Read oneGET /api/service-desk/requests/{id}
Edit fieldsPATCH /api/service-desk/requests/{id}
Change statePOST /api/service-desk/requests/{id}/transition
AssignPOST /api/service-desk/requests/{id}/assign
Add a notePOST /api/service-desk/requests/{id}/updates

Create takes buildingId (required), subject, description (required), priority (low medium high critical), category, a location (spaceId, storeyId, elementId — all internal UUIDs, see Identity), reporter fields, channel, and free-form formData / metadata.

Use "channel": "system" for tickets your integration creates. It is a real channel value, it shows up in the UI, and it is the cheapest way for an operator to tell a mirrored ticket from one a human filed.

Correlating the two records

There is no externalId field on a ticket. Carry your identifier in metadata:

json
{
  "buildingId": "…", "description": "Течь в трубе под потолком",
  "channel": "system", "spaceId": "…",
  "metadata": { "sourceSystem": "acme-fm", "externalId": "WO-2026-4471" }
}

Two consequences to design around:

  1. Nothing enforces uniqueness on metadata. Posting the same payload twice creates two tickets. Keep the mapping on your side — your id → our id — and check it before creating.
  2. Store both values we return. id (UUID, what every later call needs) and number (the human-readable ticket number your users will quote).

Our state machine constrains your mapping

A transition is validated, not accepted blindly:

new         → triaged | cancelled
triaged     → in_progress | waiting | resolved | cancelled
in_progress → waiting | resolved | cancelled
waiting     → in_progress | resolved | cancelled
resolved    → closed | reopened
reopened    → in_progress | resolved
closed, cancelled → terminal

So a mirror cannot jump straight from new to in_progress, and nothing reopens a closed ticket — a returning problem becomes resolved → reopened, or a new ticket. Map your states onto this graph before you build, and when your system has fewer states, walk ours through the intermediate step rather than trying to skip it.

Per-request-type workflows can restrict these edges further (they can never add one), so a transition that works for one request type may be rejected for another. Treat a 400 from /transition as "not allowed here", not as a bug.

Two things that surprise people

Creating a ticket here can immediately change it

Routing rules run on create. A ticket you post as new may come back already triaged, with a team assigned and a priority bumped, plus system notes on its timeline. That is the platform working as configured — but if your sync treats "our record changed" as "push the change back", the very first mirrored ticket starts a loop. Ignore changes whose origin is your own write: compare against what you sent, or stamp the write in metadata and skip the echo.

SLA clocks start when the ticket is created here

slaFirstResponseDueAt and slaResolutionDueAt are computed from the SLA policy at the moment of creation in our system, and the first assignment counts as the first response. Backfilling a three-day-old ticket gives it a fresh clock, not the original one — so an SLA report built on the mirror will disagree with the source system. If SLA is what the customer cares about, keep it on the owning side and treat the other side's clocks as decorative.

Work orders

If CAFM is in use, work orders are linked to tickets by the platform itself: cafm.work-order.created stores a workOrderId on the ticket and adds a system note; .updated adds progress notes; .completed moves a ticket that is triaged, in_progress or waiting to resolved (the reporter can still reopen it). Those handlers are idempotent, so redelivery does not duplicate notes or transitions.

For an external integration this matters in one way: a ticket can change state without anyone touching it through your integration. Your polling loop must tolerate a state it did not cause.

A workable shape for "your system owns"

  1. New ticket in your system → POST /requests with channel: "system", metadata.externalId, and the resolved spaceId. Store the returned id.
  2. Field change in your system → PATCH /requests/{id}; state change → POST /requests/{id}/transition, walking legal edges.
  3. Every few minutes → poll the open states, compare updatedAt against what you last saw, and reconcile the fields you do not own (assignment, notes, work-order links).
  4. Never write back a value that arrived in step 3.

Step 3 is polling because ticket events do not reach external subscribers today, and the list endpoint has no updatedSince filter — see Out of the twin. Both are known gaps rather than deliberate design; if they block you, tell us, because a named blocked integration is what gets them scheduled.

Built on the Tango Vision platform. Questions? developers@tango.vision