Skip to content

Migrating from the Live Discover REST API

Audience and scope

This guide is for developers with an existing integration against the Live Discover REST API who need to move it to the Live Endpoint Search (LES) GraphQL API.

This guide covers endpoint queries only — the workflow that runs osquery-style SQL against live, connected endpoints in real time. Live Discover also has a separate data lake query path, for querying historical data instead of live endpoints, behind its own distinct API. That path isn't covered here, and LES's schema doesn't currently expose a way to run a data lake query at all — only to list saved queries tagged for either source. If your integration runs data lake queries today, confirm a migration path with the API owner before you rely on anything in this guide for that traffic.

This isn't a drop-in replacement. The transport changes from several REST resources to a single GraphQL endpoint. Endpoint targeting also changes shape: REST selects endpoints inline on the run request, but LES pins an endpoint set to a session before you run a query against it. That session flow is the only supported way to target endpoints on LES — send the required matchEndpoints argument as { all: false } once you set sessionId, rather than trying to filter endpoints through it directly.

The schema also lets runLiveDiscoverQuery's matchEndpoints argument carry filters directly, without a session. Don't use that path — it isn't surfaced in the Sophos Central Admin UI and isn't the supported way to target endpoints through the API. The session flow above is what the API expects integrators to use.

For each REST resource, this guide covers:

  • The equivalent LES GraphQL query or mutation.
  • The request and response shape at a workflow level.
  • Behavioral differences worth checking before you rely on them.
  • Capabilities LES adds that REST doesn't have.

Mappings for the REST side come from the Live Discover REST API v1 specification. Mappings for the GraphQL side come from the Live Endpoint Search GraphQL API reference.

A ⚠️ marks a decision you need to make for your integration, or a question this guide couldn't resolve from the schema alone.

What changed

Aspect Live Discover REST v1 Live Endpoint Search GraphQL
Transport Multiple REST resources under /live-discover/v1 One GraphQL endpoint with Query and Mutation operations
Query catalog GET /queries/categories, GET /queries liveDiscoverCategories, liveDiscoverQueries, liveDiscoverQuery
Query authoring No API. Use Designer Mode in Sophos Central Admin createLiveDiscoverQuery, updateLiveDiscoverQuery, deleteLiveDiscoverQuery
Endpoint targeting matchEndpoints filters or IDs, sent inline on the run request Create a session, assign endpoints with endpointSessions, then run the query against that session
Run a query POST /queries/runs runLiveDiscoverQuery
List query runs GET /queries/runs liveDiscoverQueryRuns
Run status GET /queries/runs/{runId} liveDiscoverQueryRun
Endpoint telemetry GET /queries/runs/{runId}/endpoints liveDiscoverQueryRunEndpoints
Results GET /queries/runs/{runId}/results liveDiscoverQueryRunResults, liveDiscoverQueryRunResultsCsv
Cancel a run No API cancelLiveDiscoverQueryRun
Enrichment pivots GET/POST /enrichment-pivots, PATCH/DELETE /enrichment-pivots/{id} ⚠️ Not covered by this guide
Rate limits 10 query runs per minute, 500 per day 10 per minute, 1,000 per day — shared with scheduled queries, plus new device- and tenant-level guardrails
Authentication Bearer JWT and X-Tenant-ID Same bearer JWT and X-Tenant-ID headers

Authentication doesn't change. Send the same token and tenant header to your configured GraphQL endpoint instead of a regional REST host. See Getting started as a Partner, as an Organization, or as a Tenant.

Workflow-by-workflow mapping

List query categories

REST: GET /queries/categories

GraphQL: Query.liveDiscoverCategories

query LiveDiscoverCategories {
  liveDiscoverCategories {
    items {
      id
      name
      description
    }
  }
}

liveDiscoverCategories takes no arguments. GET /queries/categories/{categoryId} reads one category by ID on REST; there's no single-category query on LES. Filter the items list client-side instead.

List and get saved queries

REST: GET /queries accepts categoryId, search, searchFields, pageSize, page, and pageTotal. GET /queries/{queryId} reads one query by ID.

GraphQL: Query.liveDiscoverQueries and Query.liveDiscoverQuery

query LiveDiscoverQueries {
  liveDiscoverQueries(params: { categoryId: "<category-id>", pageSize: 25 }) {
    items {
      id
      name
      description
      template
      supportedOSes
    }
  }
}
query LiveDiscoverQuery {
  liveDiscoverQuery(id: "<query-id>") {
    query {
      id
      name
      description
      template
      supportedOSes
    }
  }
}

liveDiscoverQueries' params argument accepts categoryId, search, searchFields, pageSize, page, and pageTotal — every REST filter carries over. It also adds a dataSource filter (endpoints, dataLake) that REST's GET /queries doesn't have.

supportedOSes uses the same flat enum on both APIs (linuxServer, macOSComputer, windowsComputer, windowsServer) — request it as a plain field, not a selection set. No value mapping needed.

Create, update, and delete queries

REST: No API. Query authoring only happens through Designer Mode in Sophos Central Admin.

GraphQL: Mutation.createLiveDiscoverQuery, Mutation.updateLiveDiscoverQuery, Mutation.deleteLiveDiscoverQuery

mutation CreateLiveDiscoverQuery {
  createLiveDiscoverQuery(
    input: {
      name: "Running PowerShell processes"
      description: "Lists processes matching powershell.exe"
      template: "SELECT pid, name, path, cmdline FROM processes WHERE name = 'powershell.exe';"
      supportedOSes: [windowsComputer, windowsServer]
      categories: [{ id: "<category-id>" }]
    }
  ) {
    query {
      id
      name
    }
  }
}
mutation UpdateLiveDiscoverQuery {
  updateLiveDiscoverQuery(id: "<query-id>", input: { name: "Updated query name" }) {
    query {
      id
      name
    }
  }
}

mutation DeleteLiveDiscoverQuery {
  deleteLiveDiscoverQuery(id: "<query-id>") {
    result {
      deleted
    }
  }
}

This is new capability, not a REST replacement. If your integration currently automates query creation by driving the Sophos Central Admin UI, these mutations remove that workaround. categories and supportedOSes are both required and need at least one entry.

LES also adds catalog-level category authoring with no REST equivalent: createLiveDiscoverCatalogCategory, updateLiveDiscoverCatalogCategory, and deleteLiveDiscoverCatalogCategory. REST has no API for creating or renaming a category.

Find and target endpoints

REST: No standalone search call on live-discover-v1. POST /queries/runs selects endpoints inline with a matchEndpoints object: all: true, or up to five filters objects (healthStatus, type, tamperProtectionEnabled, lockdownStatus, ids, lastSeenBefore, lastSeenAfter, hostnameContains, associatedPersonContains, groupNameContains, os). One REST call both selects endpoints and starts the run. A standalone endpoint search does exist as a separate product — the Endpoint API — if your integration already calls that to pick endpoints, you can keep doing so, or switch to liveDiscoverEndpointSearch to do it from inside the same schema.

GraphQL: LES splits this into three calls. First, optionally search for endpoints:

query FindEndpoints {
  liveDiscoverEndpointSearch(
    input: {
      healthStatus: ["good"]
      type: ["computer", "server"]
      hostnameContains: "web-"
    }
  ) {
    items {
      id
      hostname
      os {
        platform
        name
      }
    }
  }
}

Then create a session and assign the selected endpoints to it:

mutation CreateSession {
  createLiveDiscoverSession {
    session {
      name
    }
  }
}
{
  "data": {
    "createLiveDiscoverSession": {
      "session": {
        "name": "<session-name>"
      }
    }
  }
}
mutation AssignEndpoints {
  endpointSessions(
    input: {
      sessions: ["<session-name>"]
      action: add
      filter: { ids: ["<endpoint-id-1>", "<endpoint-id-2>"] }
    }
  ) {
    requestedAt
    completedAt
  }
}
{
  "data": {
    "endpointSessions": {
      "requestedAt": "2026-09-10T16:57:00.034Z",
      "completedAt": "2026-09-10T16:57:00.084Z"
    }
  }
}

liveDiscoverEndpointSearch and endpointSessions share one filter input, and it's a superset of REST's matchEndpoints.filters: every REST filter field carries over (tamperProtectionEnabled becomes tamperProtection, now a list instead of a single boolean), plus LES-only fields such as ipAddresses, serialNumberContains, tags, groupIds, and mdrManaged. One structural difference: REST's filters is an array of up to five filter objects; LES's filter is a single object, with no way to send multiple separate filter groups in one call. ⚠️ The published spec doesn't state how REST combines multiple filter objects. Confirm that behavior against your own data before you assume it and try to reproduce it with several liveDiscoverEndpointSearch calls merged client-side.

Wherever your integration currently sends matchEndpoints.filters inline on a run request, add the session and endpointSessions calls ahead of it. There's no supported way to select endpoints and start a run in a single GraphQL call.

Run a saved or ad hoc query

REST: POST /queries/runs takes savedQuery.queryId or adHocQuery.template/adHocQuery.name, an optional variables array (name, dataType, value, pivotType), and matchEndpoints.

GraphQL: Mutation.runLiveDiscoverQuery

mutation RunSavedQuery($savedQuery: SavedQueryInput, $sessionId: ID!, $matchEndpoints: MatchEndpointsInput!) {
  runLiveDiscoverQuery(
    input: { savedQuery: $savedQuery, sessionId: $sessionId, matchEndpoints: $matchEndpoints }
  ) {
    executionSummary {
      id
      status
    }
  }
}
{
  "variables": {
    "savedQuery": { "queryId": "<query-id>" },
    "sessionId": "<session-name>",
    "matchEndpoints": { "all": false }
  }
}
{
  "data": {
    "runLiveDiscoverQuery": {
      "executionSummary": {
        "id": "<run-id>",
        "status": "started"
      }
    }
  }
}

For an ad hoc query, use adHocQuery in place of savedQuery:

mutation RunAdHocQuery {
  runLiveDiscoverQuery(
    input: {
      adHocQuery: {
        name: "Running PowerShell processes"
        template: "SELECT pid, name, path, cmdline FROM processes WHERE name = 'powershell.exe';"
      }
      sessionId: "<session-name>"
      matchEndpoints: { all: false }
    }
  ) {
    executionSummary {
      id
      status
    }
  }
}

matchEndpoints stays required on runLiveDiscoverQuery even though targeting happens through endpointSessions. Send the non-selecting value shown above ({ all: false }) once a session is set — don't use matchEndpoints.filters here; that's not the supported integration path.

REST's variables[].name/dataType/value/pivotType map one-to-one onto LES's query variable input, and the dataType (boolean, dateTime, double, integer, text) and pivotType enums use the same values on both APIs. LES's input adds an optional description field with no REST equivalent.

Check run status

REST: GET /queries/runs/{runId}

GraphQL: Query.liveDiscoverQueryRun

query CheckRun {
  liveDiscoverQueryRun(runId: "<run-id>") {
    executionSummary {
      id
      status
      result
      resultCount
    }
  }
}
{
  "data": {
    "liveDiscoverQueryRun": {
      "executionSummary": {
        "id": "<run-id>",
        "status": "started",
        "result": "notAvailable",
        "resultCount": 2211
      }
    }
  }
}

result stays notAvailable while a run is in progress. resultCount updates as endpoints report back — this real run matched 2,211 rows from a single endpoint before it finished, which is exactly the kind of volume that makes selection-set cost worth planning for before you page through the full result set.

Cancel a run

REST: No API.

GraphQL: Mutation.cancelLiveDiscoverQueryRun

mutation CancelRun {
  cancelLiveDiscoverQueryRun(runId: "<run-id>") {
    executionSummary {
      id
      status
    }
  }
}

This is new capability. REST has no way to stop a run once it starts.

List query runs

REST: GET /queries/runs returns a paged, sortable list of your tenant's query runs.

GraphQL: Query.liveDiscoverQueryRuns

query ListRuns {
  liveDiscoverQueryRuns(
    params: { pageSize: 50, executionStatus: [finished] }
  ) {
    items {
      id
      status
      result
      createdAt
    }
    pages {
      current
      size
    }
  }
}

Read per-endpoint telemetry

REST: GET /queries/runs/{runId}/endpoints

GraphQL: Query.liveDiscoverQueryRunEndpoints

query RunEndpoints {
  liveDiscoverQueryRunEndpoints(
    runId: "<run-id>"
    params: { pageSize: 50 }
  ) {
    items {
      id
      hostname
      status
      result
      resultCount
    }
    pages {
      current
      size
    }
  }
}

Retrieve results and CSV export

REST: GET /queries/runs/{runId}/results returns a cursor-paged page of result rows. Pass the previous page's pages.nextKey back as the pageFromKey query parameter to get the next page.

GraphQL: Query.liveDiscoverQueryRunResults

query GetResults {
  liveDiscoverQueryRunResults(
    runId: "<run-id>"
    params: { pageSize: 50 }
  ) {
    items {
      endpointId
      hostname
      additionalColumns
    }
    pages {
      fromKey
      nextKey
      size
    }
    metadata {
      columns {
        name
        type
      }
    }
  }
}
{
  "data": {
    "liveDiscoverQueryRunResults": {
      "items": [
        {
          "endpointId": "<endpoint-id>",
          "hostname": "<hostname>",
          "additionalColumns": "{\"pid\":2192,\"name\":\"powershell.exe\",\"path\":\"C:\\\\Windows\\\\System32\\\\WindowsPowerShell\\\\v1.0\\\\powershell.exe\",\"cmdline\":\"powershell.exe\"}"
        }
      ],
      "pages": {
        "fromKey": "<from-key>",
        "nextKey": "<next-key>",
        "size": 50
      },
      "metadata": {
        "columns": [
          { "name": "pid", "type": "integer" },
          { "name": "name", "type": "text" },
          { "name": "path", "type": "text" },
          { "name": "cmdline", "type": "text" }
        ]
      }
    }
  }
}

Each result row (ExecutionResultItem) carries the endpoint it came from plus additionalColumns, a JSON-encoded string of the SQL query's own output columns. Read metadata.columns on the same response to know which keys and types to expect. REST returned every column flattened onto the row instead.

Pagination itself doesn't change: params.pageFromKey on liveDiscoverQueryRunResults works exactly like REST's pageFromKey query parameter. Pass the previous response's pages.nextKey as the next request's pageFromKey to get the next page — same field, same meaning, on both APIs.

Selection-set cost. A broad query (SELECT *, no WHERE clause) can return thousands of rows from one endpoint. One real run against a file-monitoring query matched 2,211 rows from a single endpoint alone. At the default pageSize, that's dozens of pages. pages.maxSize caps a single page at 1,000 rows, no matter what you request. Before paging through the full result set:

  • Check liveDiscoverQueryRun.resultCount first, so you know how much data you're dealing with before you start requesting pages.
  • Select specific columns in your query template instead of SELECT *additionalColumns only ever contains what your SQL asked for, so a narrower SELECT means a smaller payload per row.
  • Keep pageSize modest for interactive use. A page of wide rows (long file paths, JSON blobs) adds up fast across many endpoints and many pages.

REST has no CSV export on this API. GraphQL adds one:

query GetResultsCsv {
  liveDiscoverQueryRunResultsCsv(runId: "<run-id>") {
    csvData
  }
}

The response field is csvData. There's no equivalent for a separate, endpoint-level CSV export — only the query result set exports to CSV.

Enrichment pivots

REST: GET/POST /enrichment-pivots and PATCH/DELETE /enrichment-pivots/{enrichmentPivotId} manage canned and custom enrichments (IP address, hash, DNS, port, and geolocation lookups) that Live Discover result rows can pivot into.

GraphQL: ⚠️ Not covered by this guide. Confirm a replacement with the API owner before you migrate an integration that manages enrichment pivots.

Field and enum reference

REST field GraphQL field Notes
matchEndpoints.filters[].healthStatus (validated enum) healthStatus on liveDiscoverEndpointSearch/endpointSessions input ([String!], not a validated enum) Same values expected (good, suspicious, bad, unknown), but LES won't reject a typo the way REST's enum validation does.
matchEndpoints.filters[].type (validated enum) type on the same input ([String!], not a validated enum) Same values expected (computer, server, securityVm). securityVm is deprecated on both APIs.
matchEndpoints.filters[].tamperProtectionEnabled (Boolean) tamperProtection ([Boolean!]) Renamed, and changed from a single value to a list.
matchEndpoints.filters[].lockdownStatus (validated enum) lockdownStatus on the same input ([String!], not a validated enum) Same values expected, same caveat as healthStatus/type.
matchEndpoints.filters[].ids ids Same shape.
matchEndpoints.filters[].lastSeenBefore/.lastSeenAfter lastSeenBefore/lastSeenAfter Same shape.
matchEndpoints.filters[].hostnameContains hostnameContains Same shape.
matchEndpoints.filters[].associatedPersonContains associatedPersonContains Same shape.
matchEndpoints.filters[].groupNameContains groupNameContains Same shape.
matchEndpoints.filters[].os (structured object) os (JSON) LES accepts this as a loosely typed JSON value instead of a structured input. Validate the shape against a real response before you rely on it.
matchEndpoints.all matchEndpoints: { all: true \| false } Same shape, but see Run a saved or ad hoc query — send false once you're using a session.
variables[].dataType (double, integer, text, dateTime, boolean) Same enum, same values 1:1.
variables[].pivotType (deviceId, deviceName, sophosPid, ipAddress, username, sha256, filePath, registryKey, url) Same enum, same values 1:1.
savedQuery.queryId savedQuery: { queryId } Same shape.
adHocQuery.template, .name adHocQuery: { template, name } Same shape.
supportedOSes (flat enum: linuxServer, macOSComputer, windowsComputer, windowsServer) Same enum, same values 1:1.

Resolving dynamic reference data

Not applicable to this migration. Cases-style APIs resolve type/status/verdict as tenant-licensed reference data looked up by ID. Live Discover has no equivalent: query categories and saved queries are returned directly by ID and name on both APIs, and aren't gated by licensed services. Skip this step.

⚠️ One open question this guide can't answer from the schema alone: whether a query or category ID from the REST catalog resolves to the same ID on LES, or whether you need to remap IDs during cutover. Confirm ID portability with the API owner before you migrate stored query or category IDs.

Filter and search translation

REST parameter GraphQL equivalent Notes
GET /queries?categoryId= liveDiscoverQueries(params: { categoryId }) Same shape.
GET /queries?search=&searchFields= liveDiscoverQueries(params: { search, searchFields }) Same shape.
matchEndpoints.filters[] (any field) liveDiscoverEndpointSearch(input: { ... }), then endpointSessions See Find and target endpoints. Every REST filter field carries over; LES adds more.
matchEndpoints.all: true matchEndpoints: { all: true } Same shape.

Pagination

REST GraphQL
Saved query list Offset (page/pageSize, default 50) params: { page, pageSize, pageTotal } — same offset style
Category list Not paginated — returns the full list Not paginated — liveDiscoverCategories takes no arguments
Query run list Offset (page/pageSize) plus sort params: { pageSize }, with pages { current, size } in the response
Endpoint telemetry page/pageSize params: { pageSize }, with pages { current, size } in the response
Results Cursor — request pageFromKey, response pages.fromKey/pages.nextKey Identical: request params.pageFromKey, response pages.fromKey/pages.nextKey

Rate limits

REST limits query runs to 10 per minute and 500 per day, per tenant.

LES counts API and scheduled queries together against one limit: 10 per minute and 1,000 per day. If you exceed either, the API returns an error that your account has exceeded its query limit for the current window.

LES also enforces guardrails REST doesn't have:

  • A query that runs on a device for more than 12 seconds at over 30% CPU, or that uses more than 256 MB of memory, gets terminated on that device.
  • Each device's response to a single query is capped at 10 MB, and any individual result row at 1 MB.
  • Across every responding device, a query run's combined results are capped at 100,000 rows. Past that cap, Sophos Central discards further rows and halts the run.

⚠️ Result retention and result-availability windows for a finished run aren't documented yet. Confirm with the API owner before you rely on fetching results any specific length of time after a run finishes.

New capabilities with no REST equivalent

  • Query authoring: createLiveDiscoverQuery, updateLiveDiscoverQuery, deleteLiveDiscoverQuery. REST has no API for this; Designer Mode is UI-only today.
  • Catalog category authoring: createLiveDiscoverCatalogCategory, updateLiveDiscoverCatalogCategory, deleteLiveDiscoverCatalogCategory.
  • Cancel a run in flight: cancelLiveDiscoverQueryRun.
  • Standalone endpoint search from inside the schema: liveDiscoverEndpointSearch, with a richer filter set than matchEndpoints.filters (ipAddresses, serialNumberContains, tags, groupIds, mdrManaged, and more). If your integration already uses the Endpoint API to select endpoints, this lets you do it without a second API call.
  • CSV export: liveDiscoverQueryRunResultsCsv.

Migration checklist

  • [ ] Point your GraphQL client at your tenant's GraphQL endpoint, reusing the bearer JWT and X-Tenant-ID header you already send to /live-discover/v1.
  • [ ] Add the session step. Anywhere your integration sends matchEndpoints inline on POST /queries/runs, call createLiveDiscoverSession and endpointSessions first, then pass the session name as sessionId on runLiveDiscoverQuery. Send matchEndpoints: { all: false } on the run itself.
  • [ ] Rename tamperProtectionEnabled to tamperProtection and change it from a boolean to a list wherever you build an endpoint filter.
  • [ ] If your integration sends more than one matchEndpoints.filters object today, confirm how REST combines them, then either fold that into a single liveDiscoverEndpointSearch filter or issue one call per group and merge the results — LES's filter input only takes one group per call.
  • [ ] Confirm query and category ID portability with the API owner before you migrate stored REST IDs.
  • [ ] Update any rate-limit pacing your integration does for REST's 500-per-day cap — LES shares a 1,000-per-day cap with scheduled queries, still at 10 per minute. Handle the new per-device (10 MB) and aggregate (100,000-row) response caps if you run queries with large result sets.
  • [ ] Confirm result retention and availability windows for finished runs with the API owner — not documented yet.
  • [ ] Confirm a replacement for enrichment pivot management (GET/POST /enrichment-pivots, PATCH/DELETE /enrichment-pivots/{id}) with the API owner before you migrate an integration that manages them — not covered by this guide.
  • [ ] Add GraphQL error handling. A query error can return HTTP 200 with an errors array.
  • [ ] Evaluate the new capabilities (cancelLiveDiscoverQueryRun, query and category authoring, standalone endpoint search, CSV export) against workarounds your integration currently uses.