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). One list is 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.
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_, 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.
