Pagination
List endpoints page by cursor:
GET /v1/quizzes?limit=50GET /v1/quizzes?limit=50&cursor=<next_cursor from the previous page>limitis 20 by default, at least 1 and at most 100.- The response is
{"data": [...], "has_more": true, "next_cursor": "..."}. Whenhas_moreis false,next_cursoris null. - A cursor is opaque and positional: it names a place in the list, never a filter. Send it back unchanged with the same filters, and leave it out on the first page (an empty
cursor=is refused). A malformed cursor answers422 validation_error. - Cursor lists are newest first and stable under inserts: a row created after the first page was read appears on a later request of the first page, never in the middle of a walk.
- A query parameter the route does not declare answers
422 validation_errornaming it.
Which lists page how
Every cursor list is newest first: quizzes, versions, exports, webhook endpoints, deliveries and events by creation time, people by last seen. A people list also answers total, returning_total and total_capped, and a search sends the same JSON body with every page while cursor and limit stay query parameters (see People). Two kinds of list are different. The store’s quiz analytics pages by offset and limit (default 25, at most 1000) in the order of your sort and dir, and answers total. The experiment lists page by offset and limit (default 200, at most 500) and answer {"data": [...]} alone, with no has_more or next_cursor: the walkers below do not fit them, so ask for the next offset until a page comes back shorter than limit.
Walking a list
Every walker below sends no cursor on the first page and the previous next_cursor afterwards:
url="https://api.octaneai.com/v1/quizzes?limit=100&status=published"while :; do page=$(curl -s "$url" -H "Authorization: Bearer $OCTANE_API_KEY") echo "$page" | jq -r '.data[] | "\(.id) \(.name)"' [ "$(echo "$page" | jq -r '.has_more')" = "true" ] || break url="https://api.octaneai.com/v1/quizzes?limit=100&status=published&cursor=$(echo "$page" | jq -r '.next_cursor | @uri')"doneimport osimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}
def walk(url, **params): params = {**params, "limit": 100} while True: r = httpx.get(url, params=params, headers=HEADERS, timeout=30) r.raise_for_status() page = r.json() yield from page["data"] if not page["has_more"]: return params["cursor"] = page["next_cursor"]
for quiz in walk("https://api.octaneai.com/v1/quizzes", status="published"): print(quiz["id"], quiz["name"]) # quiz_2ccb8aebb27c463fa783cafb3bf858e0 Skin routine finderconst HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}` };
async function* walk<T>(url: string, params: Record<string, string> = {}): AsyncGenerator<T> { let cursor: string | null = null; while (true) { const qs = new URLSearchParams({ ...params, limit: "100", ...(cursor ? { cursor } : {}) }); const res = await fetch(`${url}?${qs}`, { headers: HEADERS }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const page = await res.json(); yield* page.data as T[]; if (!page.has_more) return; cursor = page.next_cursor; }}
for await (const quiz of walk<{ id: string; name: string }>("https://api.octaneai.com/v1/quizzes", { status: "published" })) { console.log(quiz.id, quiz.name); // quiz_2ccb8aebb27c463fa783cafb3bf858e0 Skin routine finder}package main
import ( "encoding/json" "fmt" "log" "net/http" "net/url" "os")
type page[T any] struct { Data []T `json:"data"` HasMore bool `json:"has_more"` NextCursor *string `json:"next_cursor"`}
func walk[T any](base string, params url.Values, each func(T)) { params.Set("limit", "100") for { req, _ := http.NewRequest("GET", base+"?"+params.Encode(), nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } if res.StatusCode != 200 { log.Fatalf("status %d", res.StatusCode) } var p page[T] json.NewDecoder(res.Body).Decode(&p) res.Body.Close() for _, row := range p.Data { each(row) } if !p.HasMore { return } params.Set("cursor", *p.NextCursor) }}
type quiz struct { ID string `json:"id"` Name string `json:"name"`}
func main() { walk("https://api.octaneai.com/v1/quizzes", url.Values{"status": {"published"}}, func(q quiz) { fmt.Println(q.ID, q.Name) // quiz_2ccb8aebb27c463fa783cafb3bf858e0 Skin routine finder })}For a search, the body travels with every page:
import osimport httpx
HEADERS = {"Authorization": f"Bearer {os.environ['OCTANE_API_KEY']}"}body = {"has_marketing_consent": True} # any search body; see People
def walk_search(body): params = {"limit": 100} while True: r = httpx.post("https://api.octaneai.com/v1/profiles/search", json=body, params=params, headers=HEADERS, timeout=30) r.raise_for_status() page = r.json() yield from page["data"] if not page["has_more"]: return params["cursor"] = page["next_cursor"]
print(sum(1 for _ in walk_search(body))) # how many people matchedconst HEADERS = { Authorization: `Bearer ${process.env.OCTANE_API_KEY}`, "Content-Type": "application/json" };const body = { has_marketing_consent: true }; // any search body; see People
async function* walkSearch(body: object) { let cursor: string | null = null; while (true) { const qs = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) }); const res = await fetch(`https://api.octaneai.com/v1/profiles/search?${qs}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body), }); if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); const page = await res.json(); yield* page.data; if (!page.has_more) return; cursor = page.next_cursor; }}
let count = 0;for await (const _ of walkSearch(body)) count++;console.log(count); // how many people matchedpackage main
import ( "bytes" "encoding/json" "fmt" "log" "net/http" "net/url" "os")
func main() { body := []byte(`{"has_marketing_consent": true}`) // any search body; see People params := url.Values{"limit": {"100"}} count := 0 for { req, _ := http.NewRequest("POST", "https://api.octaneai.com/v1/profiles/search?"+params.Encode(), bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("OCTANE_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { log.Fatal(err) } if res.StatusCode != 200 { log.Fatalf("status %d", res.StatusCode) } var p struct { Data []json.RawMessage `json:"data"` HasMore bool `json:"has_more"` NextCursor *string `json:"next_cursor"` } json.NewDecoder(res.Body).Decode(&p) res.Body.Close() count += len(p.Data) if !p.HasMore { break } params.Set("cursor", *p.NextCursor) } fmt.Println(count) // how many people matched}Ids
Ids on the REST API and in webhook payloads are prefixed strings: quiz_, ver_, sess_, prof_, expt_, arm_, exp_, whe_, whd_, evt_, vis_, each followed by 32 hexadecimal characters (the storefront JS API mints its own evt_ ids with a dashed uuid). An id of the wrong kind, of another store, or that does not exist answers 404 not_found.
