Pagination
Regular lists count pages from one, external voice catalogs from zero. Plus a third shape for chunking previews and a field the spec promises that does not exist.
The public API has two incompatible page numbering bases and three different response
shapes. There is no universal paginator: code written against GET /orders will silently skip the first page
of the ElevenLabs catalog.
Shape 1: regular lists, pages from one
GET /orders, GET /templates/browse, GET /voices, GET /voices/library, GET /voices/public
put an object in data:
{
"items": [ /* … */ ],
"current_page": 1,
"last_page": 5,
"per_page": 20,
"total": 93
}The query parameters are page (starting at 1) and per_page. The list is exhausted when
current_page === last_page.
Gotcha
Four descriptions of the per_page parameter in the spec promise that “pagination metadata is in data.meta”.
There is no meta field in any response schema or in any example: the counters sit directly
in data, next to items. Affected: GET /voices, GET /voices/library, GET /voices/public
and GET /orders/chunks/preview/{previewId}. Read data.current_page, not data.meta.current_page.
List Orders
Shape 2: external voice catalogs, pages from zero
GET /voices/elevenlabs/library and GET /voices/heygen proxy third-party APIs and keep their
conventions. data contains a different wrapper:
{
"voices": [ /* … */ ],
"total": 1420,
"page": 0,
"page_size": 30,
"has_more": true
}There are four differences at once: the array is called voices, not items; the page size is page_size,
not per_page; the continuation flag is the boolean has_more, and there is no last page number at all;
and page counts from zero. A fifth difference hides inside: total means different things
in these two operations. For ElevenLabs it is the number of voices on the current page (the spec explicitly says
to look at has_more, not total); for HeyGen it is how many voices match the filters
across all pages.
Careful
page=1 in these two operations is the second page. The most common complaints, “the ElevenLabs catalog
returned something different from the UI” and “the first 30 voices went missing”, are exactly this.
The first page here is page=0, which is also the default.
GET /voices/elevenlabs/library depends on the external ElevenLabs API: when that API is unavailable,
the operation responds with 503. A catalog crawl must survive an interruption midway: store the page
number, not just the accumulated array.
HeyGen Voices
Shape 3: chunking preview pages
GET /orders/chunks/preview/{previewId} resembles neither the first shape nor the second: the counters
sit in a separate data.page object and combine both styles.
| Field | Meaning |
|---|---|
data.page.current_page | page number, counting from 1 |
data.page.per_page | page size (default 100, maximum 500) |
data.page.total | total chunks in the split, across all pages |
data.page.has_more | whether there are more pages; there is no last_page here |
The neighboring data.summary field covers the entire text, not the current page, so you can't sum
it across pages. And remember that {previewId} is a sha256 of 64 hex characters, not a UUID.
What isn't paginated at all
Not all lists are paginated, and trying to page through them looks like “the server ignores page”.
| Operation | data shape |
|---|---|
GET /templates | a flat array of all templates, no pages. For page-by-page traversal, use GET /templates/browse |
GET /orders/{order}/items | a flat array of all the order's chunks, including retries |
GET /usage, GET /voices/tags, GET /voices/heygen/filters | flat arrays of entries |
List Templates
Ceilings and defaults
| Operation | Parameters | Range | Default |
|---|---|---|---|
GET /orders | page, per_page | page ≥ 1, per_page 1..100 | 20 |
GET /templates/browse | page, per_page | page ≥ 1, per_page 1..100 | 15 |
GET /orders/chunks/preview/{previewId} | page, per_page | page ≥ 1, per_page 1..500 | 100 |
GET /voices/elevenlabs/library | page, page_size | page ≥ 0, page_size ≥ 1 | 0 / 30 |
GET /voices/heygen | page, page_size | page ≥ 0, page_size 1..200 | 0 / 55 |
GET /voices, /voices/library, /voices/public | per_page | no ceiling declared in the spec | 20 |
An out-of-range value returns 422 with the field in errors; it is not silently truncated.
- What happened
- The only API response that comes OUTSIDE the common envelope: `{"message": "…", "errors": {"field": ["…"]}}` — it has no `success` field. Code like `if (!res.success)` works on a 422 only by coincidence, because it is comparing against `undefined`.
- What to do right now
- First check whether `errors` is present. If it is, fix the request according to the listed fields. If it is not, but there is a `reason`, the request shape is not the problem, and repeating the same body gets the same refusal; read the page for that specific code.
Gotcha
For the three voice lists (GET /voices, GET /voices/library, GET /voices/public) the spec
declares only per_page, even though the response carries current_page and last_page. In other words, they do have pages,
but the parameter for moving between them is undocumented. Pass page as you would for the other
shape 1 lists, and check current_page in the response to confirm it was applied.
There is no cursor pagination
Neither cursor, nor next_cursor, nor next_page_url exists in the public API: there is not a single
mention of them in the entire OpenAPI spec. The trap is that the ?pag=cursor parameter passes validation
and does not return 422, but is silently ignored: the list still comes back in offset form. Page
through with page / per_page.
How to traverse an entire list
Offset pagination doesn't give you a consistent snapshot: the data changes while you walk through the pages.
GET /orders returns the newest orders first, so a single newly created order shifts the whole listing forward
by one position, and you will see the record at a page boundary twice or not at all.
- Deduplicate by
idon the client side. This is the only safeguard that works in all three shapes. A set of already seenids is cheaper than trying to guess the shift. - Narrow the selection so that it doesn't grow. The
GET /ordersfilters arestatus,task_type[], andtemplate_id. Iterating over terminal statuses gives an almost stable set, whereasstatus=in_progresschanges right under you. - Stop on a flag, not a counter. For shape 1, stop when
current_pagereacheslast_page; for external catalogs, whenhas_more === false.totalis recalculated on every request and is fine for a progress indicator, but not for an exit condition. - Use larger pages, but not the maximum.
per_page=100instead of 20 cuts the number of requests fivefold, and with it the risk of hitting a429during the crawl. Half the maximum page size is a reasonable compromise between response size and request count. - Pause between pages. Crawling a large catalog is the most common way to hit
the key's request-rate limit, which has no
Retry-After; see Limits and the 429 code.
Do
Keep a set of seen ids, page until has_more === false or
current_page === last_page, and survive a 503 from the external catalog by resuming from the same page number.
Don't
Compute the total page count as Math.ceil(total / per_page) once at the start and then blindly
iterate over the numbers: total will be stale by the third page, and for zero-based catalogs
the last number will be last - 1, so you will request the empty tail twice.
Feed this page to an AI
Updated 09/04/2026 · spec 0.0.1