Calling the API from Python: Parameters, Pagination and Errors
Every op takes path values positionally, sends keyword fields as the JSON body for writes and as query parameters for reads, follows all four AlertOps paging styles through .iter() and .all(), and raises one APIError subclass per HTTP status.
Overview
Relevant for Developers writing their first real integration
Every endpoint in the SDK is an Op bound to a client, and every op follows the same call convention, so once you know it you can call any of the 784 ops from docs/REFERENCE.md without reading its code. This article covers how arguments map to the HTTP request, how pagination works, what the SDK raises on failure, which failures it retries, and how to observe calls for logging or metrics.
Key benefits:
- One convention — path values, body fields, query parameters, bare-array bodies and multipart uploads all use the same signature.
- Paging handled — the four AlertOps paging styles hide behind .iter() and .all().
- One exception tree — three different error body formats become e.messages, a plain list of strings.
- Safe retries — idempotent requests retry on throttling and gateway errors, and POSTs never retry when they could have reached the server.
The call convention
Relevant for Developers mapping an API reference row to a Python call
Every op has this signature:
op(*path_values, [body], body=None, params=None, files=None, headers=None, **fields)
| Argument | What it does |
| *path_values | Fill the {placeholders} in the path, in template order. Values are URL-quoted, so a user name with @ is safe. |
| path params by keyword | ao.groups.get(group_id=12345) works the same as ao.groups.get(12345). |
| one extra positional | The request body. Use it for endpoints that take a bare JSON array. |
| body= | The request body, given by keyword. Keyword **fields merge into it when it's a dict. |
| **fields | For POST, PUT and PATCH (and DELETEs that take a body): merged into the JSON body. For GET and DELETE: query parameters. |
| params= | Always query parameters, whatever the method. |
| files= | Send multipart/form-data instead of JSON. Body fields become form fields. |
| headers= | Extra headers for this call only. |
Query values are normalised: None is dropped, True/False become true/false, and lists are joined with commas.
ao.groups.get(12345) # GET /groups/12345
ao.groups.list(search="NOC", limit=25) # GET /groups?search=NOC&limit=25
ao.groups.update_basic(12345, group_name="NOC Primary", # PUT /groups/12345/basic
description=["Network operations"]) # with a JSON body
ao.groups.members.add(12345, [ # bare JSON array body
{"member_type": "User", "member": "101", "sequence": 1, "roles": ["Primary"]},
])
ao.users.list(params={"include": "roles"}, search="ann") # params= and fields combine on GET
Some ops carry defaults that are merged under your fields. For example, ao.escalation_policies.create defaults priority="Critical", enabled=True and quick_launch=False, and anything you pass wins.
Multipart uploads
Pass files= with the same tuple format httpx uses: {"file": (filename, content, content_type)}.
with open("members.csv", "rb") as f:
ao.groups.members.import_csv(files={"file": ("members.csv", f.read(), "text/csv")})
ao.alerts.attachments.upload(98765, files={"file": ("log.txt", b"disk full", "text/plain")})
ao.status_hub.pages.assets.upload_logo(42, files={"file": ("logo.png", png_bytes, "image/png")})
Argument errors raised before any request
| Error | Cause |
| TypeError: <Op>() missing path parameter 'group_id' | A path placeholder got no value. |
| ValueError: <Op>(): path parameter 'group_id' is empty | A path value is None or "". This stops a call such as DELETE /groups/ from hitting the wrong route. |
| TypeError: <Op>() does not take a request body | You passed a body to a GET (or a DELETE without a body). |
| TypeError: <Op>() got too many positional arguments | More positionals than path values plus one body. |
| TypeError: <Op>(): cannot merge keyword fields into a list body | You passed a bare-array body and keyword fields together. |
Methods on every op
| Method | HTTP | What it does |
| op(...) | the op's method | Runs one request and returns the parsed result. |
| op.iter(...) | the op's method, once per page | Yields every item across all pages. On AsyncAlertOps it's an async iterator. |
| op.all(...) | the op's method, once per page | Collects every item across pages into an AOList. |
| op.lazy(...) | none | Returns a Call to yield from a flow (see Async Client and Composable Flows). |
| op.lazy_all(...) | none | Same, but the flow receives every item across pages. |
| op.op | none | The underlying Op: .method, .path, .base, .path_params, .op_id. |
| ao.request(method, path, base=…, auth=…) | any | Calls a path the SDK doesn't model, with the same convention. |
Pagination
Relevant for Developers listing more than one page of anything
Call the op directly for one page, .iter() to stream every item, or .all() to collect them. Filters you pass to .iter() or .all() are sent on every page.
page = ao.users.list(limit=25, offset=1) # one page: rows 26-50
for u in ao.users.list.iter(search="ann"): # every page, lazily
print(u.user_name)
everything = ao.escalation_policies.list.all() # AOList of every policy
AlertOps uses four paging styles. The SDK knows which one each list op uses:
| Style | Used by | Request | Stops when |
| Page index | v2 lists: users.list, groups.list, escalation_policies.list, response_plays.list, inbound.list, workflows.list, technical_services.list, admin.audit_trail.list | limit + offset, where offset is 0, 1, 2 … | A page has fewer than limit rows. |
| v3 cursor | status_hub.incidents.list, status_hub.maintenance.list, status_hub.services.list, status_hub.pages.subscribers.list | pageSize + after=<nextCursor>; response {data, nextCursor, hasMore} | nextCursor is empty or hasMore is false. |
| Page number | status_hub.notifications.incidents, status_hub.notifications.maintenance_windows | pageSize + pageNumber, starting at 1 | A page has fewer than pageSize rows. |
| Alerts cursor | alerts.list | limit + next=<metadata.next>; response {alerts[], metadata{limit, previous, next}} | metadata.next is empty. |
A fifth, single-page style covers inbound.log.list: it sends limit only, so .iter() yields one page.
offset is a page index, not a row offset
Verified live: with limit=3, offset=1 returns rows 4–6, not rows 2–4. If you page by hand, add 1 to offset per page. .iter() and .all() already do this.
Alert lists need a date window and viewBy="A"
GET /alerts filters on createdFrom / createdTo (YYYY-MM-DD), and createdTo is exclusive, so pass tomorrow to include today. Without viewBy="A" you only see the caller's own alerts. ao.alerts.list_recent(days=1) sets both for you.
import datetime as dt
today = dt.date.today()
for alert in ao.alerts.list.iter(viewBy="A", createdFrom=str(today - dt.timedelta(days=7)),
createdTo=str(today + dt.timedelta(days=1))):
print(alert.alert_id, alert.status)
.iter() on an op that isn't paginated still works: it runs the request once and yields the items of the result (the unwrapped list, or the data / items / results list of an envelope).
Errors
Relevant for Developers handling failures
Every non-2xx response raises a subclass of APIError, chosen by status. All SDK exceptions inherit from AlertOpsError.
| Status | Exception | Typical cause |
| 400, 422 | BadRequestError | Invalid body or unknown name ("Invalid User : <name>"). |
| 401 | AuthenticationError | Missing or invalid API key. |
| 402 | PaymentRequiredError | Feature not on the plan, for example call routing. |
| 403 | PermissionDeniedError | The key's user isn't entitled, or the route rejects API keys. |
| 404 | NotFoundError | Unknown id or route. |
| 409 | ConflictError | Conflicts with existing state. |
| 429 | RateLimitError | Throttled, after automatic retries. |
| other 4xx | APIError | Any status without its own class. |
| 5xx | ServerError | Server failure. |
| network | TransportError | DNS, connect or timeout failure after retries. |
| before sending | ConfigurationError, UnsupportedAuthError | No API key, no base URL, or a session-only op without a session token. |
from alertops import APIError, BadRequestError, NotFoundError
try:
ao.groups.get(999)
except NotFoundError as e:
print(e.status, e.messages) # 404 ['Group: 999 not found']
print(e.op, e.method, e.url) # Groups.get GET https://app.alertops.com/api/v2/groups/999
except APIError as e:
print(e.status, e.body) # the raw parsed body, for anything unexpected
| Attribute | Description |
| status | HTTP status code. |
| messages | List of human-readable messages, normalised from any of the error shapes below. |
| op | Op id, for example Groups.get (Resource.op_name). |
| method, url | The request that failed. |
| body | The parsed response body (dict, list or text). |
str(e) is "<status> <method> <url> [<op>]: <messages joined by '; '>", which is what you see in a traceback.
Error body shapes
AlertOps answers errors in several formats. e.messages flattens all of them:
| Source | Body | e.messages |
| v2 | {"errors": [{"message": "Group: 999 not found"}]} | ["Group: 999 not found"] |
| v2 | {"reason": "Invalid API Key"} | ["Invalid API Key"] |
| v3 | {"message": "Status page with ID 9 not found"} | ["Status page with ID 9 not found"] |
| v3 problem+json | {"title": "One or more validation errors", "errors": {"name": ["required"]}} | ["name: required"] |
A plain-text body becomes a one-item list, and the AIOps add-on's {"error": [{"Message": …}]} is handled too.
Retries
Relevant for Developers running the SDK under load or on flaky networks
The client retries up to max_retries times (default 3, so at most 4 attempts) with exponential backoff: backoff_factor * 2**attempt seconds (0.5 s, 1 s, 2 s …), capped at backoff_max (8 s), with ±25% jitter. A Retry-After header replaces the computed delay, capped at four times backoff_max (32 s by default).
| Request | Retried on | Never retried on |
| GET, HEAD, PUT, DELETE, OPTIONS | 429, 502, 503, 504, and any network error | 500, and every 4xx except 429 |
| POST, PATCH | 429, and connect errors or connect timeouts (the request never reached the server) | 5xx and read timeouts, because the server may already have acted |
PUT and DELETE count as idempotent
AlertOps PUTs replace whole records, so repeating one is safe. A POST is retried only when it can't have created anything, so a retry never opens a duplicate alert or creates a second group.
Set max_retries=0 to turn retries off, for example in tests that assert on the first response.
Listeners for logging and metrics
Relevant for Developers adding observability
add_listener(fn) registers a callable that receives one event per completed request. The live test suite uses the same hook to record coverage.
import logging
def record(event: dict) -> None:
# {"op": "Groups.list", "method": "GET", "url": "...", "status": 200, "elapsed": 0.21}
# Network failures: {"op", "method", "url", "status": 0, "error": "ConnectError(...)"}
logging.info("%s %s -> %s", event["op"], event["method"], event["status"])
ao.add_listener(record)
ao.groups.list(limit=5)
ao.remove_listener(record)
- Events fire once per final response, not once per retry attempt.
- An exception inside a listener is logged and swallowed, so it never breaks a call.
- The SDK also logs each request at DEBUG on the alertops logger: GET <url> -> 200 (210 ms).
Best Practices
Relevant for Anyone running SDK code in production
Pagination
- Use .iter() for large lists so rows stream instead of loading at once.
- Don't page by hand. If you must, remember offset counts pages, not rows.
- Always send viewBy="A" and a date window to alerts.list, or use alerts.list_recent.
Errors
- Catch the narrowest class you can handle (NotFoundError for "already gone"), then APIError as a fallback.
- Log e.op and e.messages, not only str(e): they name the op and the server's reason.
- Treat PaymentRequiredError and PermissionDeniedError as configuration facts about the tenant, not as transient failures.
Writes
- Many AlertOps PUTs replace the whole record. Prefer the read-merge-write helpers (update, update_options, modify, patch) listed in docs/REFERENCE.md over a raw PUT with a partial body.
Configuration Examples
Relevant for Starting templates for common call patterns
Example 1 — Delete if it exists
from alertops import NotFoundError
try:
ao.groups.delete(12345)
except NotFoundError:
pass # already gone
ao.groups.delete_if_exists(12345) # the same thing, as a built-in helper (returns True/False)
Example 2 — Export every user to a list of dicts
rows = [{"id": u.user_id, "name": u.user_name}
for u in ao.users.list.iter(params={"include": "roles"})]
Example 3 — Status Hub incidents through the v3 cursor
for inc in ao.status_hub.incidents.list.iter(includeResolved=True, pageSize=50):
print(inc.get("incidentId"), inc.get("title"))
Example 4 — Plan-gated feature check
from alertops import PaymentRequiredError
try:
numbers = ao.call_routing.list()
except PaymentRequiredError as e:
print("Call routing not on this plan:", e.messages)
Troubleshooting
Relevant for Anyone debugging failed calls
PaymentRequiredError: "Package settings do not have CallRouting enabled."
- The tenant hasn't purchased the call routing add-on. Every /call_routing* route answers 402, even for an unknown id. The /app/call_routing/* pickers still work.
- Quick start updates answer 402 "Package settings do not have Quickstart enabled" on plans without it.
PermissionDeniedError: "The API key doesn't have permissions to perform the request"
- Some routes reject API keys even for the account Owner (location and holiday writes). Make the change in the UI.
- Business services and v2 business incidents answer 403 when the tenant isn't entitled to them.
BadRequestError: "Invalid User : <name>" but NotFoundError for an id
- GET /users/{x} accepts an id or a user name. An unknown id is 404 "User does not exist", an unknown name is 400. Use ao.users.find_by_username(name), which returns None for both.
Only one page comes back
- You called the op directly, which returns one page. Use .iter() or .all().
- For alerts, the default viewBy shows only your alerts, and a new alert can take a few seconds to appear in the list.
TypeError: does not take a request body
- You passed a positional body or body= to a GET. Pass filters as keyword fields or params=.
TransportError after a long wait
- The network failed on every attempt. With the defaults a GET makes 4 attempts with up to 8 s between them. Check connectivity and timeout, or lower max_retries to fail faster.
Related Articles
- Getting Started with the AlertOps Python SDK
- Async Client and Composable Flows in the Python SDK
- Working with Alerts in the AlertOps SDKs
- Status Hub Pages, Incidents and Maintenance with the AlertOps SDKs
- Testing and Verifying the Python SDK
Need help?
Email support@alertops.com or contact your AlertOps representative.