Skip to content

Migrating from the Cases REST API

Audience and scope

This guide is for developers with an existing integration against the Sophos Central Cases REST API (cases-v1) who need to move it to the Cases GraphQL API.

If you are building a new GraphQL integration, start with Manage the case lifecycle.

This is not a drop-in swap. Beyond the change in protocol, three things changed shape:

  • Case types, statuses and verdicts are no longer fixed enums. They are managed reference data that you look up by ID at runtime, and which types and statuses a tenant has access to depends on its licensed services.
  • Evidence on a case is now a set of ID references. You resolve each type against the API that owns it, rather than getting full payloads inline.
  • Assignees are identity IDs, not email addresses.

For each REST endpoint, this guide covers:

  • The equivalent GraphQL query/mutation
  • Field-level mapping, including where fixed enums became reference data
  • Request and response examples for both APIs
  • Gaps with no direct equivalent, and what to do instead
  • Capabilities the GraphQL API adds that REST did not have

Mappings are based on the Cases GraphQL schema and version 1.2.0 of the Cases v1 OpenAPI specification.

Some of the detail this guide references (full detection records, host/asset records, raw event records, the broader entity/relationship graph) lives outside the Cases API, in a small number of additional GraphQL APIs. The Detections and Events APIs are documented on this site. The Assets and Entity Graph APIs are not published here yet. Each section below names the specific queries and fields you need from them. Where this guide refers to one of those APIs, it uses these labels:

Label used in this guide What it covers
Detections API Full detection records. The direct replacement for REST's detections endpoints, including MITRE, geolocation, associated entities, resolution status
Assets API Full host/endpoint asset records (hardware, OS, network identifiers, isolation status, tags)
Entity Graph API The broader entity graph (users, hosts, IPs, files, processes, domains) plus edges linking them to alerts/events/investigations, with threat-intel/geo/WHOIS enrichment
Events API Raw sensor event records, with optional original data and federated detection or MITRE ATT&CK details

Identity fields are the exception to that pattern: reading who a case is assigned to, or who created or closed it, needs no separate call, because those resolve directly on the Case type. Setting an assignee does need an identity lookup first, since assigneeId takes a Subject ID or an @mention rather than an email. See §2.9 and §3.5.

One terminology note: the platform historically called these records alerts, and is standardising on detection to match the naming you already know from Sophos Central. That transition is still in progress, so both names appear in the API today:

  • Operations exist under detection* names, which reflect the current terminology, and under older alertsService* names that behave identically (for example detectionRetrieveById and alertsServiceRetrieveAlertsById). Prefer the detection* form; this guide uses it throughout.
  • Types and response fields have not all been renamed yet, so you will still see the older wording inside a response. Calling detectionRetrieveById, for example, returns its records under an alerts field. Read those as detections.

A ⚠️ marks either a decision you will need to make for your own integration, or a behavior difference worth checking before you rely on it.


1. What changed

Aspect REST v1 GraphQL
Transport Many REST endpoints, one resource per verb Single /graphql endpoint, one Query/Mutation type with many fields
Filtering Discrete query params (type, severity, status, assignee, …) One query: String field using QL, plus tenantServiceFilters
type / status / verdict Fixed enums baked into the API Managed reference data (caseTypes, casePrimaryStatuses/caseSecondaryStatuses, casePrimaryVerdicts/caseSecondaryVerdicts) resolved by ID, not by name. Availability depends on the tenant's licensed services
severity String enum (notSet, critical, high, medium, low, informational) Int!. See §3.1 for the value mapping. Being an integer means you can query ranges, e.g. "high and above"
Case identifier Single caseId (e.g. 3-201650) Two identifiers: opaque id: ID! (used in all arguments/mutations) and human-readable shortId (CSE#####)
Case content overview: String (plain text, ≤20,000 chars) keyFindings: KeyFindingsDocument, a typed envelope (documentType, documentVersion, content)
Detections/entities on a case Full detection & entity payloads inline (sensor, MITRE, geolocation, reputation…) caseEvidence returns IDs only, with one follow-up call per evidence type: detectionsEvidence → the Detections API, eventsEvidence → the Events API, assetsEvidence → the Assets API. Entities are not case evidence; the Entity Graph API returns them for a case, keyed on the case id. See §2.6 and §2.7.
Assignee/creator/updater identity Raw strings (assignee is an email) Reading: Case.assigneeSubject/createdBySubject/updatedBySubject/closedBySubject are federated fields; request them in the same query, with no extra call. Writing: assigneeId takes a Subject ID or an @mention, never an email, so setting an assignee needs an identity lookup first (see §3.5)
Comments / attachments / links Not supported Supported directly: caseComments, caseFile(s), createCaseLink
Delete DELETE /cases/{caseId} (self-managed only) No delete mutation. Cases can be closed and archived, but not hard-deleted
Pagination page/pageSize (max 50) Offset (page/perPage, max 100) or cursor-based (first/after/last/before)
Auth Bearer JWT + X-Tenant-ID header, regional REST host (api-{region}.central.sophos.com) Same OAuth2 client-credentials flow, same headers, different endpoint. See note below.

Auth: same credentials, same headers, different endpoint. You authenticate for this API exactly the way you already do for cases-v1: the same client_credentials grant against the Sophos OAuth2 token endpoint, to get a bearer JWT. See Getting started as a Partner, as an Organization or as a Tenant for a refresher on obtaining a service principal. Send that token, along with the same X-Tenant-ID/X-Partner-ID headers you already send today, to the Cases GraphQL API's single endpoint, https://api.taegis.sophos.com/graphql, instead of a regional REST host (api-{dataRegion}.central.sophos.com).


2. Endpoint-by-endpoint mapping

2.1 List and search cases

REST: GET /cases/v1/cases

GET https://api-<data-region>.central.sophos.com/cases/v1/cases?status=new&status=investigating&severity=high&pageSize=50
Authorization: Bearer <jwt>
X-Tenant-ID: <tenant-id>

GraphQL: Query.cases

query {
  cases(arguments: {
    query: "primaryStatusId in ('<new-status-uuid>', '<investigating-status-uuid>') and severity = 8"
    pagination: { offset: { page: 1, perPage: 50 } }
  }) {
    totalCount
    pageInfo { startCursor endCursor hasNextPage hasPreviousPage }
    cases {
      id
      shortId
      title
      severity
      primaryStatus { id name title }
      secondaryStatus { id name title }
      type { id name title }
      managedBy
      assigneeId
      createdAt
      updatedAt
      closedAt
      detectionsCount
    }
  }
}

The main change: REST's discrete type=, severity=, status=, assignee=, verdict= query parameters collapse into one QL string on the query argument, and type/status/verdict are filtered by UUID, not by name. See §4 for how to resolve names to IDs, and §5 for a REST-parameter-by-parameter translation table.

keyFindings/overview is not returned by the list query. See §2.3.

2.2 Create a case

REST: POST /cases/v1/cases

{
  "name": "Suspicious PowerShell activity",
  "type": "incident",
  "severity": "high",
  "status": "new",
  "assignee": "jane.doe@example.com",
  "initialDetectionId": "2e0cdd5ffec_3bad8fb8f",
  "otherDetectionIds": ["a1b2c3d4e5"],
  "overview": "Multiple encoded PowerShell commands observed on EC2AMAZ-C9BOKG4."
}

GraphQL: Mutation.createCase

mutation {
  createCase(input: {
    title: "Suspicious PowerShell activity"
    typeId: "<incident-type-uuid>"
    severity: 8
    primaryStatusId: "<new-status-uuid>"
    assigneeId: "<subject-id-for-jane.doe>"  # a Subject ID, NOT an email. See §3.5
    detectionIds: ["2e0cdd5ffec_3bad8fb8f", "a1b2c3d4e5"]
    keyFindings: {
      documentType: MARKDOWN
      documentVersion: "1.0"
      content: "Multiple encoded PowerShell commands observed on EC2AMAZ-C9BOKG4."
    }
  }) {
    id
    shortId
    title
  }
}

Field mapping for create/update payloads:

REST field GraphQL field Notes
name title Same validation intent (max length, allowed punctuation), but re-validate against the GraphQL schema's own constraints (max 256 chars on title, vs. REST's 510).
type (enum) typeId (ID) Resolve via caseTypes (§4). REST's generalRequest/managedRisk types were excluded from create; equivalent tenant-side restrictions apply to GraphQL case types too (check caseTypes for what's creatable).
severity (enum) severity (Int) See mapping table in §3.1.
status (enum) primaryStatusId (+ optional secondaryStatusId) Resolve via casePrimaryStatuses/caseSecondaryStatuses (§4).
assignee (email string / "Unassigned") assigneeId (String) Takes a Subject ID or an @mention, never a raw email address, despite being a String. See §3.5. Omit/leave null for unassigned rather than passing a sentinel string.
initialDetectionId + otherDetectionIds detectionIds GraphQL merges both into one list. Everything attached at creation time is flagged as genesis evidence, meaning the evidence the case was originally opened on rather than evidence added later, so REST's initial vs. other split has no equivalent to preserve. You can read the flag back later as isGenesis (§2.6).
overview keyFindings.content REST took a plain string; GraphQL expects a typed envelope. Use documentType: MARKDOWN and documentVersion: "1.0". Both are required.
verdict primaryVerdictId / secondaryVerdictId Set when creating a case in a closed status or when moving an existing case to a closed status. Resolve via casePrimaryVerdicts/caseSecondaryVerdicts (§4).
escalated (no field) No equivalent field exists on Case. ⚠️ If your integration relies on this flag, you will need to decide how to represent it yourself; tags is the most direct option.
managedBy managedBy (CUSTOMER/PROVIDER) selfCUSTOMER, sophosPROVIDER. This field only has meaning for provider-managed (MDR) cases. For a purely self-managed (XDR) tenant it is not a meaningful distinction and must be left as CUSTOMER/unset.

2.3 Get a case by ID

REST: GET /cases/v1/cases/{caseId} (e.g. caseId = 3-201650)

GraphQL: Query.case

query {
  case(arguments: { id: "<case-id>" }) {
    id
    shortId
    title
    keyFindings { documentType documentVersion content }
    severity
    type { id name title }
    primaryStatus { id name title }
    secondaryStatus { id name title }
    primaryVerdict { id name title }
    secondaryVerdict { id name title }
    assigneeId
    assigneeSubject { id }
    managedBy
    createdAt
    updatedAt
    closedAt
    closeReason
    riskScore
    tags
    links { id url title type }
  }
}

keyFindings (REST's overview) is returned only by this single-case query. It is omitted from the list query (cases) for performance.

⚠️ Legacy cases are not retrievable through this API — the two systems hold separate sets of cases. Cases created in the legacy Sophos Central Cases system keep the old caseId format (^[A-Za-z0-9]+-[A-Za-z0-9]+$, for example 3-201650); cases in Fusion get their own id (UUID) and shortId (CSE#####). Passing a legacy caseId to Query.case will not return a result, since there is no translation between the two ID formats and legacy cases are not migrated into the new API.

Any legacy case IDs your integration has stored remain readable only through the legacy API. If you need that history to survive the move, export it on your side rather than planning to re-resolve those IDs against the new API later.

2.4 Update a case

REST: PATCH /cases/v1/cases/{caseId} (partial update; only self-managed cases)

GraphQL: Mutation.updateCase, also a PATCH-style partial update.

mutation {
  updateCase(input: {
    id: "<case-id>"
    primaryStatusId: "<resolved-status-uuid>"
    severity: 6
    assigneeId: "<subject-id-for-jane.doe>"  # NOT an email. See §3.5
    closeReason: "Confirmed benign PowerShell logging tool."
  }) {
    id
    primaryStatus { name }
    severity
    assigneeId
  }
}

The field mapping from create (§2.2) applies here too. Three behavioral differences to be aware of:

  • What you can update depends on who manages the case. Self-managed cases are broadly editable. Provider-managed (MDR) cases are not read-only, but the set of fields a customer can change is narrower; closing a case, for example, is permitted. The REST restriction ("self-managed only") does not map across unchanged, so if your integration acts on MDR cases, verify the specific fields you need are accepted for that case.
  • Verdict-on-close enforcement. If the target primaryStatusId is a closed status and the resolved case type supports verdicts, updateCase will reject the request unless a primary verdict is present. REST's status-only model had no equivalent gate. If your integration closes cases without ever setting a verdict, add verdict resolution to your update path.
  • Archiving is separate from closing. REST has no archive concept. GraphQL adds isArchived: Boolean on updateCase, only settable once a case is closed.

2.5 Delete a case

REST: DELETE /cases/v1/cases/{caseId} (self-managed cases only)

GraphQL: No equivalent. There is no deleteCase mutation. Cases are retained rather than deleted.

Use close and archive instead. Transition the case to a closed status via updateCase — with a verdict, if the case type requires one, see §2.4 — then optionally set isArchived: true to remove it from active views. For most integrations this covers what a DELETE call was being used for: taking a case out of day-to-day workflow while keeping its record.

2.6 Case evidence: detections, events and assets

REST: GET /cases/v1/cases/{caseId}/detections and GET /cases/v1/cases/{caseId}/detections/{detectionId} return full detection objects: sensor info, device, MITRE ATT&CK tactics/techniques, geolocation, Intelix file reputation, raw data, severity, time, type, etc.

GraphQL, step 1: get the linkage. Query.caseEvidence returns the three evidence types attached to a case: detections, events and assets.

query {
  caseEvidence(arguments: { id: "<case-id>" }) {
    detectionsEvidenceCount
    detectionsEvidence { id detectionId createdAt createdBy isGenesis }
    eventsEvidenceCount
    eventsEvidence { id eventId createdAt createdBy isGenesis }
    assetsEvidenceCount
    assetsEvidence { id assetId createdAt createdBy }
  }
}

These types expose only the linkage: the ID, when it was attached, who attached it, and whether it was genesis evidence. None of them carry the record itself.

Step 2: fetch the full record, by evidence type. Each type resolves against a different API:

query {
  detectionRetrieveById(in: { iDs: ["2e0cdd5ffec_3bad8fb8f"] }) {
    alerts {
      list {
        id
        metadata {
          title severity confidence description
          created_at { seconds } first_seen_at { seconds }
          origin
          creator { detector { detector_id detector_name } rule { rule_id } }
        }
        status               # the detection's own resolution status, not the case's
        sensor_types
        attack_technique_ids
        enrichment_details {
          mitre_attack_info { technique_id technique tactics description platform }
          geo_ip { ip_address latitude longitude country_code_iso asn }
        }
        source_entities { display_name subtype identifiers properties { __typename ... on EntityHost { host_id hostname os } } }
        target_entities { display_name subtype identifiers }
        event_ids { id }
      }
    }
  }
}

Field mapping, REST → the Detections API's detection record:

REST detection field Detection record field Notes
severity (Int, 1–10) metadata.severity (Float32, 0–1) Not a rescaling of the same value. Detections in the two platforms are separate records with their own severity models, so there is no conversion between REST's severity and this one. Treat them as unrelated values. Note also that Case.severity (2/4/6/8/10) is a third, separate scale.
detectionRule alerting_rules[].id / creator.rule.rule_id
type / attackType metadata.title / creator.detector.detector_name No exact 1:1 field; closest semantic matches.
time metadata.created_at (or first_seen_at for sensor-reported time) Uses a { seconds, nanos } timestamp object, not an ISO string.
sensor (id,type,source,version,name) sensor_types: [String!] + source_entities/target_entities (EntityHost.sensor_id, sensor_type) Flattened differently: sensor detail lives on the associated host entity, not as a standalone object.
device source_entities/target_entities where subtype indicates a host, exposing EntityHost properties (host_id, hostname, os, mac_address, sensor_id, …)
mitreAttacks (tactic → techniques, nested) attack_technique_ids: [String!] (flat technique IDs) and enrichment_details[].mitre_attack_info (one entry per technique, each carrying its own tactics: [String!]) Inverted nesting vs. REST (REST groups techniques under tactics; here you get one technique-detail record with a list of tactic names). Reshape client-side if you need REST's exact tactic→technique tree.
geolocation[] enrichment_details[].geo_ip (ip_address, latitude, longitude, radius, country_code_iso, asn) Close 1:1 match; REST's city/state/country/postal name fields aren't present, leaving only ISO country code and lat/long/radius.
intelixFileReputation[] (single-source Intelix score) File/domain entity properties: threat_intel_hits_csv, threat_intel_score, threat_intel_updated_at_usec (on the relevant source_entities/target_entities entry) Not a like-for-like replacement. REST's reputationScore came from Intelix alone. threat_intel_score is a weighted score derived from several threat intelligence sources, so it is a different measure rather than the same score in a new location. Treat the two as unrelated values.
rawData Event.additionalData.originalData The detection record returns contributing event IDs in event_ids. Pass those IDs to the Events API as described below.
ruleDescription metadata.description
schema (no equivalent)

The Detections API also exposes detectionRetrieveByHost, detectionRetrieveByEntity, and detectionRetrieveByGroupKey (all taking the same input shape), plus a full QL search via detectionSearch. Use these if you need to go the other direction — given a host or entity, find its detections — rather than starting from a case's evidence list.

  • eventsEvidence.eventId → the Events API. Events are raw sensor records, one level below detections. REST had no case-level equivalent. Pass each eventId unchanged to Query.events as a resource name. Get event details from cases and detections works both evidence chains end to end, including the detection event IDs below.
query CaseEventDetails($input: EventsInput!) {
  events(input: $input) {
    rn
    values
  }
}
{
  "variables": {
    "input": {
      "rns": ["<eventId-1>", "<eventId-2>"]
    }
  }
}
  • assetsEvidence.assetId → the Assets API. Assets are hosts attached to the case directly, rather than referenced inside a detection. REST had no case-level equivalent for these either.

assetId resolves to the asset's id. There is no single "get by ID" field, only a list/search query, so filter on the ID:

query {
  assetsV2(filter: { where: { id: "<assetId>" } }) {
    assets {
      id hostId
      hostnames { hostname }
      ipAddresses { ip }
      ethernetAddresses { mac }
      osFamily osVersion
      isolationStatus
      tags { tag }
    }
  }
}

The same where-input supports hostname, ipAddress, macAddress, username, tags and vulnerabilityStatus, if you need to look an asset up by something other than its ID. If your client sits behind a federated Fusion gateway, you can request asset fields through entity resolution instead.

GET /cases/{caseId}/detections/{detectionId} (single detection) maps the same way: pass a single ID to detectionRetrieveById.

2.7 Impacted entities

REST: GET /cases/v1/cases/{caseId}/impacted-entities returns entities of type user, device, ipAddress, networkFlow, file, or process, each with entityAttributes and associated detections.

GraphQL: the Cases API does not expose an entities list on the Case type. There are two ways to retrieve them, depending on whether you want them for the whole case or per detection.

(Assets attached to a case are a separate thing from entities, and are resolved as case evidence in §2.6. An asset can be represented in the entity graph, but the entity graph is not a source of a case's asset evidence.)

Option 1: request the case's entities from the Entity Graph API. This is the closest replacement for the REST endpoint, returning the enriched entity set for a case in one call.

Use the case's id (the UUID) as the entry point, not its shortId; the CSE##### form will not resolve. The entry-point type is named INVESTIGATION rather than CASE, which is historical naming in that API, and there is no CASE alias:

query {
  entityContextAssociatedEntities(
    entryPoint: { type: INVESTIGATION, ids: ["<case-id>"] }
  ) {
    entities {
      id type subtype displayName identifiers
      properties { name values }
      enrichments {
        ipGeo { countryIsoCode cityNames }
        threatContext { ip { id } domain { id } file { id } }
        asset { hostId osFamily }
        whois { domainName registrarName }
      }
    }
    count
  }
}

This is the closest match to REST's impacted-entities response, in a single request: type/subtype cover REST's user/device/ipAddress/networkFlow/file/process types, and properties/enrichments cover REST's entityAttributes along with threat intelligence, geolocation, asset and WHOIS context. Results are rolled up at the case level, so they don't indicate which detection surfaced each entity. Use option 2 if you need that attribution.

The same API also offers entityContextRelatedResources, which takes the same entry points but returns related alert/event/case IDs rather than entities. Use it to walk outward from a case to its related records, instead of down to its entities.

Option 2: read the entities carried on a specific detection. Each detection record carries its own entities in source_entities/target_entities. Use this when you are already resolving detection detail (§2.6) and want entities scoped to one detection, or when you need to know which detection an entity came from, which REST expressed as a nested list per entity:

query {
  caseEvidence(arguments: { id: "<case-id>" }) {
    detectionsEvidence { detectionId }
  }
}
query {
  detectionRetrieveById(in: { iDs: ["<detectionId-1>", "<detectionId-2>"] }) {
    alerts {
      list {
        id
        source_entities { id type: subtype display_name identifiers properties { __typename } }
        target_entities { id type: subtype display_name identifiers properties { __typename } }
      }
    }
  }
}

A note on IDs. Going into the Entity Graph API, entry points take the ID issued by whichever API owns that record, unchanged: INVESTIGATION takes a case UUID, ALERT takes a detectionId, EVENT takes an eventId. Coming back out, the id on each returned entity belongs to the Entity Graph API alone. It is not interchangeable with a detectionId, eventId or assetId, and cannot be used as an entry point in another API, so treat it as meaningful only within the entity graph.

2.8 Attack technique summary

REST: GET /cases/v1/cases/{caseId}/mitre-attack-summary returns a pre-aggregated list of MITRE ATT&CK tactics/techniques for the case.

GraphQL: there is no equivalent field on Case or CaseEvidence, and no single call that replaces it. Assemble it client-side:

  1. Get detectionsEvidence[].detectionId and eventsEvidence[].eventId from caseEvidence(arguments: { id: caseId }).
  2. For detectionIds, call the Detections API's detectionRetrieveById(in: { iDs: [...] }) and read attack_technique_ids / enrichment_details[].mitre_attack_info { technique_id technique tactics } off each detection record (§2.6).
  3. For eventIds, call events(input: { rns: [...] }). Select additionalData.mitreAttackTechniques { technique_id technique tactics }.
  4. De-duplicate the tactics and techniques from both result sets. If your consumers expect REST's nested tactic-to-technique shape, reshape the results (see the mitreAttacks row in §2.6).

Allow for the follow-up requests if your integration calls this workflow frequently. Cache results per case until its evidence changes.

2.9 Resolving people

REST returned these as raw strings (an email, or a sentinel like "Unassigned"/"MDR Ops"). GraphQL's Case type gives you the raw ID (assigneeId, createdById, updatedById, closedById) and a federated convenience field (assigneeSubject, createdBySubject, updatedBySubject, closedBySubject) that resolves to a Subject:

query {
  case(arguments: { id: "<case-id>" }) {
    assigneeId
    assigneeSubject { id name displayName }
    createdById
    createdBySubject { id name displayName }
  }
}

Add the *Subject field to your selection set alongside the *Id field, and it resolves automatically via federation to the underlying identity (user, client, or service principal) — no separate identity API call needed to display who's on a case.

⚠️ *Subject fields are best-effort. They return null when the value can't be resolved through federation, for example @mention-style group references, or a subject that has since been deleted. Because assigneeId is an opaque Subject ID rather than an email (§3.5), it is not a usable human-readable fallback if assigneeSubject comes back null. Decide up front what your UI/report shows in that case (the raw ID, "Unassigned", or a cached display name of your own) rather than assuming displayName is always populated.


3. Field and enum reference

3.1 Severity

REST (severity: String) GraphQL (severity: Int!) Meaning
critical 10 Critical
high 8 High
medium 6 Medium
low 4 Low
informational 2 Informational
notSet No equivalent: severity is non-nullable, so you must supply a concrete value. ⚠️ Pick a default (informational/2 is the closest fit) if your integration relies on an "unset" state.

severity is an integer rather than an enum so that you can filter on ranges: severity >= 8 gets you "high and above" in a single predicate. Additional values may be introduced between the existing ones, so prefer range comparisons (>= 8) over matching against a fixed set of exact values.

3.2 Status

REST's flat status enum (new, investigating, onHold, resolved, actionRequired) becomes a two-tier model:

  • primaryStatus (CasePrimaryStatus: id, name, title, isClosed, isCaseVisibleToCustomers), resolved via Query.casePrimaryStatuses
  • secondaryStatus (CaseSecondaryStatus, optional, grouped under a primary status), resolved via Query.caseSecondaryStatuses
  • secondaryStatusReason: [String!], free-form reasons scoped to the chosen secondary status's allowedSubstatusReasons

Which statuses a tenant has access to depends on its licensed services, and can change if that licensing changes. Upgrading or downgrading a service level may add or remove available statuses and types. So there is no fixed list you can rely on across tenants, or even for the same tenant over time.

Don't hardcode status IDs or assume a fixed set of names. Look them up at runtime:

query { casePrimaryStatuses(arguments: {}) { primaryStatuses { id name title isClosed } } }

Match on name to build your own name→ID lookup, and refresh it periodically rather than resolving once at install time.

3.3 Type

REST's fixed type enum (hunt, investigation, incident, healthCheck, duplicate, postureImprovement, customerRequest, activeThreat, exposure, managedRisk, generalRequest) is likewise dynamic in GraphQL. Resolve via caseTypes:

query { caseTypes(arguments: {}) { types { id name title managedBy } } }

3.4 Verdict

REST's flat verdict (falsePositive, truePositiveMalicious, truePositiveBenign, truePositive, inconclusive) becomes primaryVerdict/secondaryVerdict, resolved via casePrimaryVerdicts/caseSecondaryVerdicts. Verdicts can only be set when the case is in/entering a closed status, same restriction as REST.

3.5 Assignee

REST's assignee is an email address, "Unassigned", or (for Sophos-managed cases) "MDR Ops". GraphQL's assigneeId: String accepts two things, and an email address is not one of them:

  • A Subject ID: the federated identity ID that assigneeSubject resolves. This covers individual users as well as clients/service principals.
  • An @mention: a group reference, used to route a case to a group rather than to one person.

The field is typed as String to accommodate both forms, not because it accepts free-form text.

Assignee-setting logic therefore needs an extra step: anywhere your integration currently takes a user's email and sends it as assignee, resolve that email to a Subject ID first and send that instead. Reading is simpler: request assigneeSubject { id name displayName } alongside assigneeId (§2.9) to get a display name back in the same call.


4. Resolving dynamic reference data

Because type, status, and verdict are no longer static enums, any integration that filters, displays, or sets these values by name needs to resolve them first, using these queries:

Reference data Query Key fields
Case types caseTypes(arguments: { transitionFromId, caseId }) id, name, title, managedBy, allowedNextTypes
Primary statuses casePrimaryStatuses(arguments: { typeId, transitionFromId, caseId }) id, name, title, isClosed, isCaseVisibleToCustomers
Secondary statuses caseSecondaryStatuses(arguments: { typeId, primaryStatusId, caseId }) id, name, title, allowedSubstatusReasons
Primary verdicts casePrimaryVerdicts(arguments: { typeId, primaryStatusId, caseId }) id, name, title
Secondary verdicts caseSecondaryVerdicts(arguments: { primaryVerdictId, caseId }) id, name, title
Case sources caseSources(arguments: { transitionFromId }) id, name, title, allowedNextSources

Cache these client-side, but don't cache them forever. What a tenant has access to depends on its licensed services, so a licensing change can add or remove values.


5. Filter and search translation

REST's discrete query params all become predicates in the query: String argument. QL is the same query language used in Advanced Search.

REST param QL equivalent Notes
type=incident typeId = '<incident-type-uuid>' Resolve name → ID first (§4).
severity=high severity = 8 Use the Int mapping in §3.1.
status=new&status=investigating primaryStatusId in ('<uuid1>', '<uuid2>') Resolve names → IDs first (§4).
assignee=jane.doe@example.com assigneeId = '<subject-id-for-jane.doe>' assigneeId is a Subject ID, not an email, so resolve the target user to their Subject ID first (§3.5).
assignee=Unassigned assigneeId is null
name=Foo (exact) title = 'Foo'
overviewContains=phishing (no direct field) overview/keyFindings.content is not in the searchable schema, which exposes only flat, *Id-style columns. Free-text search over key findings content isn't currently supported.
createdAfter=... / createdBefore=... earliest='...' latest='...' ISO 8601 timestamps; relative durations are also accepted, the same way REST did.
escalated=true (no field) No escalated column exists in the searchable schema. See §2.2.
verdict=falsePositive primaryVerdictId = '<uuid>' Resolve name → ID first (§4).
page / pageSize pagination: { offset: { page, perPage } } Max perPage is 100 (vs. REST's 50).
sort=field:asc \| sort field asc (pipeline operator, inside the query string) Only flat, non-relational fields are sortable, the same limitation as filtering.

Two REST filtering behaviors have no equivalent. Filtering or sorting by the name of a related entity (status, type or verdict) is not supported, because the searchable schema is flat and exposes only the *Id columns, so resolve names to IDs first. Free-text search across the case body is also unavailable.


6. Pagination

REST GraphQL
Style Offset only Offset or cursor (pick one per request)
Page size pageSize, default 50, max 50 (>50 → 400) perPage/first/last, default 20, max 100
Total count pages.total (optional, opt-in) totalCount (always present on Cases)
Stable traversal under mutation Not guaranteed (offset drift) Use cursor mode (first/after), recommended for any integration that pages through a live, changing case list

Cursor example:

query {
  cases(arguments: {
    pagination: { cursor: { first: 50, after: "<endCursor from previous page>" } }
  }) {
    pageInfo { endCursor hasNextPage }
    cases { id shortId title }
  }
}

⚠️ Cursors are opaque — pass back exactly the endCursor (or startCursor) value the API returned, and nothing else, since its encoding is an implementation detail that can change without notice. Don't parse it, derive meaning from it, or construct or generate one yourself. If you need a durable position to resume from later, store the cursor value verbatim rather than rebuilding it from a case ID or timestamp.


7. New capabilities with no REST equivalent

None of these had a REST equivalent. If you currently work around their absence, for example by tracking case comments in a separate ticketing system, they may let you retire some of that:

  • Comments: caseComments / addCaseComment / updateCaseComment / deleteCaseComment, with @mention support. Comments also carry an isInternal flag, which restricts visibility to partner users where that applies.
  • File attachments: caseFile(s) / startCaseFileUpload (presigned upload URL flow) / deleteCaseFile.
  • Links to external systems: createCaseLink / updateCaseLink / deleteCaseLink, for attaching external ticket references (ServiceNow, 4Me, etc.) to a case. Useful if you currently maintain your own mapping of case IDs to external tickets.
  • Automatic case creation: caseRule(s) and caseTemplate(s), with their mutations, for opening cases from detection rules.
  • Split / merge: splitCase / mergeCase, for moving evidence between cases. Not available in REST at all.
  • Cursor pagination: see §6.

8. Migration checklist

  • [ ] Point your GraphQL client at https://api.taegis.sophos.com/graphql, reusing the same Sophos API client-credentials flow (id.sophos.com token endpoint) and the same X-Tenant-ID/X-Partner-ID headers you already use for cases-v1. See the Auth note in §1.
  • [ ] Build and cache a name→ID resolver for case types, primary/secondary statuses, and primary/secondary verdicts (§4); refresh on a schedule, since a tenant's licensed services determine which values it has.
  • [ ] Replace REST query params with QL predicates (§5); move any free-text/overviewContains search logic client-side or flag as unsupported.
  • [ ] Decide how to represent severity: notSet and escalated (§2.2, §3.1). Neither has a direct GraphQL equivalent.
  • [ ] If your integration is purely self-managed/XDR (no MDR provider relationship), don't invest in managedBy logic, which is only meaningful for provider-managed cases (§2.2).
  • [ ] If your integration updates provider-managed (MDR) cases, confirm which specific fields it needs are writable. The permitted set is narrower than for self-managed cases, though not empty (§2.4).
  • [ ] Build an email/user → Subject ID resolution step wherever your integration sets assignee. assigneeId takes a Subject ID or an @mention, never a raw email, unlike REST's assignee field (§2.2, §3.5, §5).
  • [ ] Add a follow-up call for each evidence type you use, since caseEvidence returns IDs only: detectionsEvidence → the Detections API, eventsEvidence → the Events API, assetsEvidence → the Assets API (§2.6).
  • [ ] Repoint anything that used REST's impacted-entities endpoint at the Entity Graph API, using the case UUID as the INVESTIGATION entry point, or resolve entities per detection if you need to know which detection surfaced each one (§2.7).
  • [ ] Don't convert between REST's detection severity (Int, 1–10) and the GraphQL detection record's metadata.severity (Float32, 0–1). They score separate records and are unrelated values, and Case.severity (2/4/6/8/10) is a third scale again (§2.6).
  • [ ] Decide how to reshape the detection record's flat, per-technique MITRE data into REST's nested tactic→technique tree, if downstream consumers expect that exact shape (§2.6, §2.8).
  • [ ] When wiring up Entity Graph API calls, always traverse in from an INVESTIGATION/ALERT/EVENT entry point (or entityContextByIdentifiers, if all you have is a property value like a hostname or file hash). Never attempt to pass Cases' detectionId/assetId/eventId directly as an entity/edge ID in that API; that ID space has no translation from any other API (§2.7).
  • [ ] Replace any mitre-attack-summary consumer with the client-side aggregation recipe in §2.8, as there is no server-side case-level summary field.
  • [ ] Replace DELETE /cases/{caseId} calls with a close (+ optional archive) flow (§2.5). Cases are retained by design, so there is no hard delete to fall back on.
  • [ ] Don't expect legacy REST caseId values to resolve through this API, because legacy cases aren't migrated into it, and no ID translation exists. Export anything you need to keep before cutover (§2.3).
  • [ ] Switch to cursor-based pagination (§6) for any integration that pages through cases while they may be actively changing. Store and replay cursor values verbatim, never constructing or parsing them.
  • [ ] Evaluate adopting comments/files/links/rules/templates (§7) instead of external workarounds.