Page through GET /v1/jobs with next_cursor: newest first (Python)

GET /v1/jobs returns newest jobs first and a next_cursor you pass back as starting_after. A Python loop pages safely and maps your idempotency keys to job ids.

4 min readSume
All posts

After a crash you often hold a list of order numbers and no job ids. GET /v1/jobs is how you rebuild the mapping, but its paging has three behaviours that a first draft of a loop usually gets wrong. This post covers them with a loop that runs.

The route returns data.jobs, an array of job records, and adds data.next_cursor only when another page exists. Each record carries id, type, status, model, idempotency_key, communication_mode and timestamps, so the idempotency key you sent at submit time comes back on the row.

Three behaviours to design for

  • Order is newest first. The first page holds your latest submissions, not your oldest, so do not stop on the first page and assume you saw the earliest.
  • The cursor is opaque. It is base64url of a timestamp and a job id, and the source comments keep the format private so it can change. Pass next_cursor back as starting_after and nothing else.
  • A cursor that cannot be decoded does not return an error. The route treats it as no cursor and returns the first page again. A truncated or stale cursor therefore restarts your loop from the newest job instead of failing, and a naive while next_cursor loop can run forever.

The loop

The function below asks for 100 jobs per page, remembers every job id it has seen and stops when a page adds nothing new, when no next_cursor comes back or after max_pages. The last line prints the mapping from your keys to job ids. The sample uses only the standard library.

import json, os, urllib.parse, urllib.request


def page(**params) -> dict:
    url = "https://api.sume.com/v1/jobs?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(url, headers={"x-api-key": os.environ["SUME_API_KEY"]})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)["data"]


def by_idempotency_key(max_pages: int = 20) -> dict:
    found, seen, cursor = {}, set(), None
    for _ in range(max_pages):
        data = page(limit=100, **({"starting_after": cursor} if cursor else {}))
        fresh = [j for j in data["jobs"] if j["id"] not in seen]
        if not fresh:  # a stale cursor restarts at the newest row
            break
        for job in fresh:
            seen.add(job["id"])
            if job.get("idempotency_key"):
                found.setdefault(job["idempotency_key"], job)
        cursor = data.get("next_cursor")
        if not cursor:
            break
    return found


jobs = by_idempotency_key()
print({k: j["id"] for k, j in jobs.items()})
assert jobs["order-2"]["id"] == "job_b"

Filters and limits

Query parameters GET /v1/jobs accepts, from the API source (read 2026-10-03)
ParameterUse
limitPage size, clamped to 1-100
starting_afterThe next_cursor from the previous page
statusNarrow to one job status
typeNarrow to one job type
scope, run_id, thread_idThread and run narrowing for agent-bound credentials

Any other query parameter returns 400 unknown_parameter, so a misspelled cursor= fails loudly rather than silently returning page one. Note the name is starting_after.

A plain API key lists only its own member's jobs. A job another member created is not in the list, and fetching it by id is a 404. That rule is covered in the jobs guide, and the full route list is in the public API reference.

Once you have the id, stop paging and poll GET /v1/jobs/{id}/status, which tells you when the job is terminal.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume