Skip to main content

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: gzip header for every request. This is mandatory. Configure your client wrapper to fail fast if this header is missing. Most HTTP libraries (such as Python requests or axios) automatically include this header and decompress payloads transparently. However, raw sockets and curl scripts do not.
  • Reuse connections using HTTP persistent connections (Keep-Alive or requests.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 429 status code, use exponential backoff with jitter and honor the Retry-After header 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 Device object with all metric families when you only need the profile field results in wasted payload sizes and unnecessary server-side processing.

  • Page through data using first: 1000 (the maximum allowed value) and iterate through end_cursor until has_next_page is false.

    NOTE

    The end_cursor field returns an empty string (not null) on the final page.

  • Filter data server-side (using loc_id, dept_id, geoloc, os_name, or event_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_range parameter 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_id and timestamp.
  • 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 errors array even when receiving an HTTP 200 status code. Partial responses are valid in GraphQL specifications.
  • Always capture the x-request-id response 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_count relative to the actual edges returned. A persistent total_count: 0 typically 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"])
NOTE

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.