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.resultCountfirst, 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 *—additionalColumnsonly ever contains what your SQL asked for, so a narrowerSELECTmeans a smaller payload per row. - Keep
pageSizemodest 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 thanmatchEndpoints.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-IDheader you already send to/live-discover/v1. - [ ] Add the session step. Anywhere your integration sends
matchEndpointsinline onPOST /queries/runs, callcreateLiveDiscoverSessionandendpointSessionsfirst, then pass the session name assessionIdonrunLiveDiscoverQuery. SendmatchEndpoints: { all: false }on the run itself. - [ ] Rename
tamperProtectionEnabledtotamperProtectionand change it from a boolean to a list wherever you build an endpoint filter. - [ ] If your integration sends more than one
matchEndpoints.filtersobject today, confirm how REST combines them, then either fold that into a singleliveDiscoverEndpointSearchfilter 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
errorsarray. - [ ] Evaluate the new capabilities (
cancelLiveDiscoverQueryRun, query and category authoring, standalone endpoint search, CSV export) against workarounds your integration currently uses.