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.
| Pattern | Who may change what | When it fits |
|---|---|---|
| Your system owns | Tickets 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 own | Tickets 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 lifecycle | You 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/…
| Action | Call |
|---|---|
| Create | POST /api/service-desk/requests |
| List / poll | GET /api/service-desk/requests?buildingId={uuid} |
| Read one | GET /api/service-desk/requests/{id} |
| Edit fields | PATCH /api/service-desk/requests/{id} |
| Change state | POST /api/service-desk/requests/{id}/transition |
| Assign | POST /api/service-desk/requests/{id}/assign |
| Add a note | POST /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:
{
"buildingId": "…", "description": "Течь в трубе под потолком",
"channel": "system", "spaceId": "…",
"metadata": { "sourceSystem": "acme-fm", "externalId": "WO-2026-4471" }
}Two consequences to design around:
- Nothing enforces uniqueness on
metadata. Posting the same payload twice creates two tickets. Keep the mapping on your side — your id → ourid— and check it before creating. - Store both values we return.
id(UUID, what every later call needs) andnumber(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 → terminalSo 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"
- New ticket in your system →
POST /requestswithchannel: "system",metadata.externalId, and the resolvedspaceId. Store the returnedid. - Field change in your system →
PATCH /requests/{id}; state change →POST /requests/{id}/transition, walking legal edges. - Every few minutes → poll the open states, compare
updatedAtagainst what you last saw, and reconcile the fields you do not own (assignment, notes, work-order links). - 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.