API Errors
EnterpriseThe error format and HTTP status codes returned by the NetStacks Controller API, and how to handle them in scripts.
Error format
Most endpoints return errors as a JSON object with a single error field holding a readable message:
{ "error": "Device not found" }There are no numeric error codes. Use the HTTP status code to decide what to do.
A few responses differ:
- Rate limits (
429) addretry_after_secsand aRetry-Afterheader:{"error":"Too many requests","retry_after_secs":42}. - Plain-text errors. The scheduled task endpoints (
/api/tasks/*) and the config snapshot endpoints (/api/devices/snapshots/*) return the message as plain text, for exampleSnapshot not found. - Request parsing errors are plain text from the web framework, for example when the JSON is malformed or a required field is missing (see the table below).
- Some errors have no body. For example, the settings endpoints return only the status code.
Parse defensively
Check the status code first. Then try to parse the body as JSON and read error; if that fails, use the raw text.
Status codes
| Status | Meaning in NetStacks | What to do |
|---|---|---|
200 OK | Success with a body | — |
201 Created | Object created (devices, templates, stacks, deployments, tasks, …), or a config pull that stored a backup | — |
202 Accepted | Work started in the background: snapshot collection, bulk config pull, task “run now” | Poll the returned object for progress |
204 No Content | Deleted | — |
400 Bad Request | The request is invalid: a business rule failed (for example “Devices require a profile”), a template failed to render, a cron expression is invalid, or the JSON body could not be parsed | Fix the request; the message says what is wrong |
401 Unauthorized | No token, an expired or invalid token, or wrong login credentials | Refresh the token or log in again |
403 Forbidden | Your roles lack the permission the endpoint needs (Insufficient permissions), or the action isn't allowed for you (for example revealing a profile secret without a reveal grant) | Ask an administrator for the right role |
404 Not Found | The object doesn't exist, or it belongs to another organization | Check the ID and the active organization |
409 Conflict | A duplicate (a name already in use), an organization that still has users or devices, or no free license seat for a terminal login | Choose another value, or free the resource |
415 Unsupported Media Type | A JSON endpoint was called without Content-Type: application/json | Add the header |
422 Unprocessable Entity | The JSON is well formed but doesn't match the expected fields (a required field is missing or has the wrong type) | Compare the body with the field tables on the endpoint page |
429 Too Many Requests | Login rate limit or failed-login lockout on /api/auth/* | Wait Retry-After seconds |
500 Internal Server Error | An unexpected error inside the Controller | Retry once; if it persists, check the API logs |
502 Bad Gateway | The Controller could not get a result from a device or upstream service, for example every method of a config pull failed. Also returned by the web proxy when the API container is down | Check device reachability and credentials, or the API container |
503 Service Unavailable | A feature's backing service is unavailable (for example session sharing without Valkey) | Check the Controller's services |
Common error messages
| Status | Message | Cause |
|---|---|---|
| 401 | Authentication required | No Authorization: Bearer header |
| 401 | Invalid or expired token | The access token expired, was signed with an old key, or is malformed |
| 401 | Invalid credentials | Wrong username or password at login (may add how many attempts remain) |
| 403 | Insufficient permissions | Missing permission for this endpoint |
| 429 | Too many failed login attempts. Please try again later. | Failed-login lockout for your IP |
| 409 | seat_limit_reached: … (seats_total: N, seats_used: N) | No free license seat for a terminal login |
| 409 | <field> "<value>" is already in use — choose a different value. | Unique value already taken |
| 400 | Can't apply this change: the selected profile no longer exists. … | The request refers to a profile, site, or other record that was deleted |
| 400 | A required field is missing. | A required value was empty |
Handling errors in scripts
import time, requests
def call(session, method, url, **kw):
r = session.request(method, url, **kw)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "30")))
r = session.request(method, url, **kw)
if r.ok:
return r.json() if r.content else None
try:
message = r.json().get("error", r.text)
except ValueError:
message = r.text # plain-text error
raise RuntimeError(f"{method} {url} -> {r.status_code}: {message}")For 401 responses, refresh the token and retry once (see the example on Authentication). Don't retry 400, 403, 404, 409, or 422 without changing the request.