Working with the ZDX GraphQL API
This article outlines best practices, error-handling information, and example requests for the ZDX GraphQL API. To learn the general concepts of the Analytics API and how to use it, see Working with the Analytics API.
Best Practices
To optimize API performance, apply the following best practices:
Transport
- Send the
Accept-Encoding: gzipheader for every request. This is mandatory. Configure your client wrapper to fail fast if this header is missing. Most HTTP libraries (such as Pythonrequestsoraxios) automatically include this header and decompress payloads transparently. However, raw sockets and curl scripts do not. - Reuse connections using HTTP persistent connections (
Keep-Aliveorrequests.Session) to avoid paying the TLS handshake penalty on every call.
Rate-Limit Hygiene
- Throttle client-side requests against your daily budget. Track consumption metrics and configure alerts to trigger at 80% usage.
- When receiving an HTTP
429status code, use exponential backoff with jitter and honor theRetry-Afterheader value if present. Never retry requests in a tight loop. - Use a single shared rate limiter per tenant credential. Running multiple scripts simultaneously under the same API key can silently exhaust your rate limits.
Query Efficiency
-
Request only the specific fields required for your use case. Requesting a full
Deviceobject with all metric families when you only need theprofilefield results in wasted payload sizes and unnecessary server-side processing. -
Page through data using
first: 1000(the maximum allowed value) and iterate throughend_cursoruntilhas_next_pageisfalse.NOTEThe
end_cursorfield returns an empty string (notnull) on the final page. -
Filter data server-side (using
loc_id,dept_id,geoloc,os_name, orevent_id) instead of retrieving the full dataset to filter within client-side code. -
You can request up to 8 domains in a single query. You do not need to populate all 8 domains. However, if you require metrics for multiple applications, combining them into a single request instead of executing individual requests helps preserve your daily API budget. Apply this pattern logically where it aligns with your workflow.
-
Rate limits are shared at the tenant level, not the team level. If multiple teams or scripts consume the API, coordinate usage internally. Share a common client wrapper, document which services consume specific limits, and consolidate overlapping data retrievals (for example, two teams sweeping the same fleet hourly consumes double the daily budget because of redundant data).
-
The
time_rangeparameter determines your data granularity, not just your scope. The API is bulk oriented, meaning metrics return aggregated over the entirety of your requested time range. A two-hour window returns a single two-hour aggregate, and you cannot recover hourly details from this payload. -
To retrieve 24 hours of data with hourly granularity, pull 24 separate one-hour windows rather than a single 24-hour block. Plan your budget accordingly: hourly granularity for a 5,000-device fleet requires approximately 120 calls per day (5 pages × 24 windows), which remains well within Tier 1 limits.
Sync Patterns
- Perform incremental synchronization by polling the last completed one-hour window every hour, then deduplicating records using the combination of
device_idandtimestamp. - Perform full-fleet resynchronizations nightly at most.
- Hardware and operating system properties (
DeviceProfile) do not change frequently, so cache them for 24 hours. Do not cache health and application performance metrics. - Schedule resource-intensive jobs during off-peak hours and distribute them across the day. Avoid consuming your entire daily budget at the start of the business day.
Error Handling
Implement the following error-handling guidelines in your API client:
- Check the GraphQL
errorsarray even when receiving an HTTP200status code. Partial responses are valid in GraphQL specifications. - Always capture the
x-request-idresponse header and include it in your application logs and support tickets. This ID is critical for the Zscaler team to debug server-side issues. - Design your error-handling logic to branch on the error code, not the message text. Only retry requests that return retryable codes. To learn more, see ZDX GraphQL API Codes.
- Use GraphQL query variables. Never string-interpolate user input directly into your query string.
- Log
total_countrelative to the actual edges returned. A persistenttotal_count: 0typically indicates an incorrect filter configuration rather than an empty dataset.
Request Examples
Use the following Python reference implementations to start your integration with the ZDX GraphQL API.
Minimal Implementation (Automatic Decompression with Requests)
The Python requests library automatically sends the Accept-Encoding: gzip, deflate header and decompresses responses transparently. However, you should still declare the header explicitly to document the requirement in your codebase.
import requests
API_URL = "https://api.zdx.example.com/graphql"
session = requests.Session() # keep-alive: reuse TLS connection
session.headers.update({
"Authorization": "Bearer <token>",
"Content-Type": "application/json",
"Accept-Encoding": "gzip", # mandatory
})
query = """
query ListDevices($from: DateTime!, $to: DateTime!, $first: Int!, $after: String) {
ZDX {
devices(first: $first, after: $after, time_range: {from: $from, to: $to}) {
total_count
page_info { has_next_page end_cursor }
edges { node { id { value } name { value } profile { os_name zdx_version } } }
}
}
}
"""
resp = session.post(API_URL, json={
"query": query,
# one-hour window: API aggregates metrics over the whole range requested
"variables": {"from": "2026-09-09T00:00:00Z", "to": "2026-09-09T01:00:00Z",
"first": 1000, "after": None},
})
resp.raise_for_status()
data = resp.json() # already decompressed by requests
if data.get("errors"): # partial errors arrive on HTTP 200
raise RuntimeError(data["errors"])
# verify compression actually happened (optional sanity check)
assert resp.headers.get("Content-Encoding") == "gzip"
Manual Decompression (Standard Library Only)
This example is designed for environments where you are working directly with raw socket streams without external library dependencies.
import gzip, json, urllib.request
req = urllib.request.Request(
API_URL,
data=json.dumps({"query": query, "variables": variables}).encode(),
headers={
"Authorization": "Bearer <token>",
"Content-Type": "application/json",
"Accept-Encoding": "gzip", # mandatory
},
)
with urllib.request.urlopen(req) as resp:
raw = resp.read()
if resp.headers.get("Content-Encoding") == "gzip":
raw = gzip.decompress(raw) # urllib does NOT auto-decompress
data = json.loads(raw)
Production Pattern with Pagination, Backoff, and Budget Management
This example demonstrates a production-ready client design. It handles persistent connections, enforces a local daily rate-limit budget, automates cursor-based pagination, and implements exponential backoff with jitter for transient errors.
import time, random, requests
class ZdxClient:
def __init__(self, token, url, daily_budget=10_000): # tier 1 default
self.s = requests.Session()
self.s.headers.update({
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"Accept-Encoding": "gzip",
})
self.url = url
self.calls_today = 0
self.daily_budget = daily_budget
RETRYABLE = {"RATE_LIMITED", "RETRY_LATER", "DATA_NOT_READY", "RESOURCE_LOCKED",
"INTERNAL_SERVER_ERROR", "SERVICE_UNAVAILABLE", "TIMEOUT",
"DEPENDENCY_FAILURE"}
def post(self, query, variables, max_retries=5):
if self.calls_today >= self.daily_budget:
raise RuntimeError("daily API budget exhausted")
for attempt in range(max_retries):
resp = self.s.post(self.url, json={"query": query, "variables": variables})
self.calls_today += 1
req_id = resp.headers.get("x-request-id") # include in every log/ticket
if resp.status_code == 429:
wait = float(resp.headers.get("Retry-After",
2 ** attempt + random.random()))
time.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
errors = data.get("errors")
if errors:
codes = {e.get("extensions", {}).get("code") for e in errors}
if codes & self.RETRYABLE:
time.sleep(2 ** attempt + random.random())
continue
# non-retryable: bad query/auth — retrying wastes budget
raise RuntimeError(f"GraphQL errors {codes} (x-request-id={req_id}): {errors}")
return data["data"]
raise RuntimeError(f"retries exhausted (last x-request-id={req_id})")
def all_devices(self, query, variables):
"""Walk all pages. end_cursor is empty string (not null) on last page."""
after = None
while True:
page = self.post(query, {**variables, "after": after})["ZDX"]["devices"]
yield from (e["node"] for e in page["edges"])
if not page["page_info"]["has_next_page"]:
break
after = page["page_info"]["end_cursor"]
client = ZdxClient("<token>", API_URL)
# 24 hours of data at hourly granularity = 24 x 1-hour windows.
# The API aggregates over the requested range; one 24h call = one 24h aggregate.
from datetime import datetime, timedelta, timezone
start = datetime(2026, 9, 9, tzinfo=timezone.utc)
for h in range(24):
frm, to = start + timedelta(hours=h), start + timedelta(hours=h + 1)
for device in client.all_devices(query, {"from": frm.isoformat(),
"to": to.isoformat(),
"first": 1000}):
print(frm, device["id"]["value"])
Although Python requests decompress gzip automatically, if you need to log raw network payload sizes, compare resp.headers.get("Content-Length") (wire payload size) against len(resp.content) (decompressed payload size) to demonstrate bandwidth efficiency.