Eviworx
Docs

Entity Linking API

Links between tickets, incidents, problems, changes, assets, KB articles and contracts run centrally via /api/linking. Every allowed pair has uniformly structured endpoints to link, unlink and list — with consistent permissions, status rules, audit and real-time updates.

🔗
Features
✓ Central entry point (/api/linking)
✓ Linkable from both sides (permission per side)
✓ Uniform endpoint pattern (link-… / linked-…s)
✓ Tiered lists (restricted for hidden entries)
✓ Sub-tickets: ticket → ticket (one level)
✓ Linking only from an open source
✓ Unlinking only when both sides are open
✓ Indirect link check (2-hop)
✓ Batch resolve via the parent entity
✓ Audit of every operation (LINKING)
✓ Real-time broadcast on both sides

Uniform Endpoint Pattern

For each registered direction of a pair (source → target) there are three endpoints. basePath is the source entity (plural), targetSlug the target entity (singular).

MethodEndpointBody / Response
POST/api/linking/{basePath}/:id/link-{targetSlug}Body { "{target}Id": "clx..." } → 204
DELETE/api/linking/{basePath}/:id/link-{targetSlug}/:targetId→ 204
GET/api/linking/{basePath}/:id/linked-{targetSlug}s{ "data": [...] }

Example: Ticket ↔ Problem

# Link (permission tickets.linkToProblems)
POST /api/linking/tickets/:id/link-problem
{ "problemId": "clx-problem-id" }          # → 204 No Content

# Remove
DELETE /api/linking/tickets/:id/link-problem/clx-problem-id   # → 204

# List the linked problems of a ticket (permission tickets.viewOwn)
GET /api/linking/tickets/:id/linked-problems
# → { "data": [
#     // visible: id, problemNumber, title, status, priority, createdAt,
#     //          assignedTo{name}|null  — WITHOUT a restricted flag
#     { "id": "...", "problemNumber": "PRB-0042", "title": "…", "status": "INVESTIGATING",
#       "priority": "HIGH", "createdAt": "…", "assignedTo": { "name": "Jane Smith" } },
#     // hidden: stub with only id, problemNumber, status, createdAt, restricted: true
#     { "id": "...", "problemNumber": "PRB-0043", "status": "NEW", "createdAt": "…", "restricted": true }
#   ] }

# Reverse direction (permission problems.linkToTickets / problems.viewOwn)
POST /api/linking/problems/:id/link-ticket   { "ticketId": "clx-ticket-id" }
GET  /api/linking/problems/:id/linked-tickets

Supported Link Pairs

Per direction the permission is {source}.linkTo{Target}; the list endpoint uses {source}.viewOwn. "↔" = both directions registered, "→" = this direction only.

PairPermissions
Ticket ↔ Problemtickets.linkToProblems / problems.linkToTickets
Ticket ↔ Changetickets.linkToChanges / changes.linkToTickets
Ticket ↔ Incidenttickets.linkToIncidents / incidents.linkToTickets
Ticket ↔ Assettickets.linkToAssets / assets.linkToTickets
Ticket → Article (KB)tickets.linkToKB
Ticket → Sub-Ticket (Ticket)tickets.linkToTickets (parent-child, see below)
Problem ↔ Changeproblems.linkToChanges / changes.linkToProblems
Problem ↔ Incidentproblems.linkToIncidents / incidents.linkToProblems
Problem ↔ Assetproblems.linkToAssets / assets.linkToProblems
Problem → Article (KB)problems.linkToKB
Change ↔ Incidentchanges.linkToIncidents / incidents.linkToChanges
Change ↔ Assetchanges.linkToAssets / assets.linkToChanges
Change → Article (KB)changes.linkToKB
Incident ↔ Assetincidents.linkToAssets / assets.linkToIncidents
Incident → Article (KB)incidents.linkToKB
Asset ↔ Contractassets.linkToContracts (custom logic, see below)

The body field matches the target entity: problemId, ticketId, changeId, incidentId, assetId, articleId or childTicketId (each a single required ID).

Behaviour & Rules

RuleDescription
Status rule (link)Source entity must not be closed/locked. Linking TO closed entities is allowed (ITIL: PIR/analytics) — with one exception: ticket → change rejects a completed change with 400 ENTITY_LOCKED.
Status rule (unlink)Both sides must be open, otherwise blocked.
Duplicate409 ALREADY_LINKED
Tiered listEntries the caller may not see come as a stub with restricted=true — the row stays (so the counter is right), the content is missing. Full rows also include the assignee substitute and, for problem pairs, the active member of the assigned group.
Scope of the rowA linked row carries exactly the fields a link list displays — number, title, status, priority, creation time and the NAME of the assignment. Everything else lives on the record detail: whole category relations, scheduling and impact fields, and internal assignment IDs (customerId, assignedToId, assignedGroupId …) are not part of these responses. If you run your own scripts against the linked routes, fetch such data from the respective detail endpoint.
TrashSoft-deleted counterparts do not appear in the lists, and neither linking nor unlinking reaches them (404). The link row itself is kept — it returns on restore. A link whose counterpart sits in the trash can therefore only be removed after restoring it.
Missing link (unlink)404 LINK_NOT_FOUND — both ends exist and are visible, only the link does not. This lets clients tell a missing link apart from a missing record.
Audit + RealtimeEvery link/unlink action is audited (category LINKING) and broadcast on both entities.
ActorLinking is a USER capability: link, unlink and list all require a logged-in user — an API key gets 403 FORBIDDEN.
Visibility of the sourceThe list uses the same rights as the record itself: whoever may not see the source gets 403 FORBIDDEN; if it does not exist or sits in the trash, 404. This way the link list never reveals more than the detail view.
Order of checksVisibility is checked before status: whoever may not see a row gets 403 on link and unlink. Otherwise the status code would reveal the state of a record they cannot see. Authorized callers get 409 for a closed record.

Sub-Tickets (Ticket ↔ Ticket)

A ticket can carry sub-tickets: one request is split into partial tasks that are worked separately. This is the only pair of two identical types and therefore directed — the ID in the URL is always the PARENT ticket, the ID in the body always the child. There is exactly ONE level: a child cannot have children of its own, and a ticket that is already a child accepts none.

# Subordinate an existing ticket (permission tickets.linkToTickets)
POST /api/linking/tickets/:parentId/link-child-ticket
{ "childTicketId": "clx-child-ticket-id" }                     # → 204 No Content

# Release the relationship
DELETE /api/linking/tickets/:parentId/link-child-ticket/clx-child-ticket-id   # → 204

# Sub-tickets of a parent ticket (tickets.viewOwn/viewAll + tickets.viewInternal)
GET /api/linking/tickets/:parentId/linked-child-tickets
# → { "data": [
#     { "id": "...", "ticketNumber": "TKT-2026-000043", "title": "…", "status": "IN_PROGRESS",
#       "priority": "HIGH", "source": "EMAIL", "createdAt": "…", "customer": { "name": "Max Mustermann" } },
#     { "id": "...", "ticketNumber": "TKT-2026-000044", "status": "OPEN", "createdAt": "…", "restricted": true }
#   ] }

The reverse direction has no list route of its own: the parent ticket appears as parentTicket (id, ticketNumber) on the child itself, together with childTicketCounts on the parent — see the Tickets API.

RejectionHTTPWhen
CANNOT_LINK_TO_SELF400A ticket cannot be its own sub-ticket.
TICKET_PARENT_TERMINAL409The parent ticket is RESOLVED, CLOSED or SPAM. A resolved parent with an open child would be a contradiction — that state must not arise via linking either. A closed CHILD may be subordinated, though: it counts as done.
TICKET_NESTING_DEPTH409The single level would be exceeded: the parent is itself a child (details.reason = PARENT_IS_CHILD) or the child carries sub-tickets of its own (CHILD_HAS_CHILDREN).
TICKET_ALREADY_HAS_PARENT409A child has exactly one parent, and it already hangs on a different one. The same parent again returns ALREADY_LINKED.
TICKET_HAS_PROCESS_LINKS409The child is linked to incidents, problems or changes (details.links names the counts per kind). Asset and KB links stay allowed.
CHILD_TICKET_CANNOT_LINK409The reverse of the same rule: a ticket that is already a child cannot be linked to an incident, problem or change — not via escalation or applying a workaround either. details.targetType names the refused kind.

The structure is internal information: The children list stays empty without tickets.viewInternal — including for the customer of the parent ticket. Child and parent can belong to different people; number and title of the other side therefore do not belong in the customer view. Empty instead of 403: the same shape in which the counters on the ticket are redacted.

Creating with parentTicketId, the lock against resolving a parent with open children, merge behaviour and the list filter are documented in the Tickets API. Tickets API →

Indirect-Link-Check

Checks whether two entities are already connected indirectly (transitive, 2-hop) — e.g. to avoid circular or redundant links before creating a direct one.

GET /api/linking/check-indirect?sourceType=TICKET&sourceId=clx-a&targetType=PROBLEM&targetId=clx-b
# → { "data": [ { "viaType": "INCIDENT", "viaId": "clx-i", "viaNumber": "INC-2026-000042", "viaTitle": "…" } ] }

sourceType and targetType accept TICKET, PROBLEM and INCIDENT — exactly the types the service knows pairs for; any other value is 400. The intermediate item passes the same visibility check as a list: whoever may not see it is not told about it here either.

Batch-Resolve

Resolves multiple linked child entities in bulk via their parent entity (cascading resolution center).

# Resolve linked tickets via an incident/problem (permission tickets.editStatus)
POST /api/linking/batch-resolve?sourceType=INCIDENT&sourceId=clx
{ "ticketIds": ["clx-t1", "clx-t2"], "resolution": "Fixed via incident", "resolutionCode": "RESOLVED_BY_INCIDENT" }

# Resolve linked incidents via a problem (permission incidents.changeStatus)
POST /api/linking/batch-resolve-incidents?sourceId=clx
{ "incidentIds": ["clx-i1"], "resolution": "...", "resolutionCode": "...", "rootCauseShort": "Missing index" }
  • sourceType — INCIDENT or PROBLEM (batch-resolve-incidents only knows problem sources and does not take the parameter). An unknown source, or one invisible to the caller, is 404 or 403.
  • Only linked rows are resolved: IDs not linked to the source count as skipped — so the response reveals nothing about whether other records exist.
  • Parent tickets with open sub-tickets are skipped: They count as skipped and appear in errors with the code HAS_OPEN_CHILDREN — the bulk resolution does not abort because of it, and the UI can name the reason per row.
  • Limits: at most 200 rows per call, resolution max 2,000 characters, rootCauseShort max 500, resolutionCode max 50.
  • The display number of the source comes from the server — it is not an input parameter; otherwise freely chosen text would end up in the ticket history, the customer notification and the audit trail.

Asset ↔ Contract

Asset-contract links have their own endpoints and rules:

MethodEndpointPermission
GET/api/linking/assets/:id/linked-contractsassets.viewOwn
POST/api/linking/assets/:id/link-contractassets.linkToContracts
DELETE/api/linking/assets/:id/link-contract/:contractIdassets.linkToContracts
// POST /api/linking/assets/:id/link-contract
{ "contractId": "clx-contract-id", "notes": "Maintenance contract" }   // → 204
  • The body is validated: contractId is required, notes optional (max 1,000 characters, null allowed); a wrong type is 400.
  • An existing link answers 409 ASSET_CONTRACT_LINK_EXISTS (the other pairs report ALREADY_LINKED).
  • This path never sets a primary contract: isPrimary stays false. The flag lives on the contract side — POST /api/contracts/:id/assets creates the assignment with the flag, PATCH /api/contracts/:id/assets/:assetId toggles it.

Impact Tree

The impact tree shows the full downstream impact of a record (direct tickets + linked incidents with their tickets = 2-hop, incl. SLA and access info). Available via the respective endpoints, e.g. GET /api/problems/:id/impact-tree and GET /api/incidents/:id/impact-tree — the basis for the close dialogs (cascading resolution).

The impact tree is tiered as well: a linked incident the caller may not see appears as a node with accessible: false — number, status and priority remain (otherwise the counter would be wrong), while title, assignment and the business fields are missing, and the resolution skips it. Whether such a node would be auto-resolvable is not stated on the row — summary.resolvableIncidentCount carries that quantity, and it counts across all linked incidents.

🔗
Core Principles
  • ✓ One central entry point for all links
  • ✓ Symmetric pairs, permission per side
  • ✓ Tiered lists + status rules
  • ✓ Indirect check against cycles
  • ✓ Batch-resolve + impact tree
🔐
Permissions (RBAC)
  • {entity}.linkTo{Targets} – Link/unlink per source
  • {entity}.viewOwn – read linked list
  • tickets.editStatus / incidents.changeStatus – Batch-Resolve
  • assets.linkToContracts – Asset↔Contract

Auth/role model: Permissions & RBAC

Related Documentation