Pagination
The Shoehorn API uses four pagination shapes today. This page documents each one, the endpoints that use it, and how to page through results.
Quick reference
Section titled “Quick reference”| Endpoint | Shape | Query params | Response key |
|---|---|---|---|
GET /entities | A: Page-shaped cursor | limit, cursor | page |
GET /search | A | limit, cursor | page |
GET /entities/zombies | A | limit, cursor | page |
GET /forge/molds | A | limit, cursor | pagination |
GET /forge/runs | A | limit, cursor | pagination |
GET /operations/resources | D: True opaque cursor | limit, cursor | top-level |
GET /repositories | B: Page-based | page, page_size | pagination |
GET /users | C: Identity provider | first, max | page |
GET /admin/users | C | page, pageSize | page |
GET /governance/actions | Limit/offset | limit, offset | total (top-level) |
GET /teams | Unpaginated | - | - |
Shape A: Page-shaped cursor (transitional)
Section titled “Shape A: Page-shaped cursor (transitional)”Used by entity, search, forge, and a few entity-detail endpoints. The cursor query parameter looks opaque but currently holds the next offset as a string. The query that runs against Postgres is LIMIT/OFFSET. The cursor naming is a holdover from an earlier migration that didn’t complete.
This will change. Per ADR-0001, these endpoints are scheduled to migrate to Shape B (page + page_size). Until then:
- Treat the cursor as opaque. Don’t parse it, don’t construct one. The format will change with the migration.
- The response includes
total, so a client can render either page-X-of-Y or prev/next UI on top.
Parameters
Section titled “Parameters”| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
limit | integer | 20 | 1 to 100 | Items per page |
cursor | string | none | - | Opaque cursor from a previous response |
Response shapes
Section titled “Response shapes”The metadata sits under either a page or pagination key. The field names differ by endpoint family.
Entities, Search, Zombies (metadata key: page)
{ "entities": [ ... ], "page": { "limit": 20, "total": 150, "nextCursor": "20", "prevCursor": null }}| Field | Type | Description |
|---|---|---|
limit | integer | Page size requested |
total | integer | Total matching items |
nextCursor | string | null | Cursor for the next page. null on the last page. |
prevCursor | string | null | Cursor for the previous page. null on the first page. |
Search returns total and took at the top level alongside the page object:
{ "results": [ ... ], "sections": { ... }, "facets": { ... }, "page": { "limit": 20, "offset": 0, "nextCursor": "20" }, "total": 150, "took": "12ms"}Forge Molds, Forge Runs (metadata key: pagination, snake_case fields)
{ "runs": [ ... ], "pagination": { "total_count": 100, "next_cursor": "20", "has_more": false }}| Field | Type | Description |
|---|---|---|
total_count | integer | Total matching items |
next_cursor | string | Cursor for the next page. Empty string when done. |
has_more | boolean | Whether more pages exist |
Example
Section titled “Example”# First pagecurl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/entities?limit=20"
# Next page: pass the previous nextCursor verbatimcurl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/entities?limit=20&cursor=20"Endpoints using Shape A
Section titled “Endpoints using Shape A”| Endpoint | Extra filters |
|---|---|
GET /entities | type, lifecycle, owner, team, tags, search, source, manifestType, hasRelations |
GET /search | q (required), types, tags, owner, lifecycle |
GET /entities/zombies | inactiveDays, minAge, lifecycle, type (limit max 200) |
GET /forge/molds | visibility, category |
GET /forge/runs | mold_slug, status |
Shape B: Page-based
Section titled “Shape B: Page-based”Used by /repositories today. The migration target for every Shape A endpoint. Numbered pages, full metadata.
Parameters
Section titled “Parameters”| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
page | integer | 1 | 1+ | Page number (1-based) |
page_size | integer | 20 | 1 to 100 | Items per page |
Response shape
Section titled “Response shape”{ "repositories": [ ... ], "pagination": { "page": 1, "pageSize": 20, "totalPages": 8, "totalItems": 150, "hasNext": true, "hasPrevious": false }}| Field | Type | Description |
|---|---|---|
page | integer | Current page number (1-based) |
pageSize | integer | Actual page size |
totalPages | integer | Total number of pages |
totalItems | integer | Total matching items |
hasNext | boolean | Whether a next page exists |
hasPrevious | boolean | Whether a previous page exists |
Example
Section titled “Example”# First pagecurl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/repositories?page=1&page_size=20"
# Page 3curl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/repositories?page=3&page_size=20"Endpoints using Shape B
Section titled “Endpoints using Shape B”| Endpoint | Extra filters |
|---|---|
GET /repositories | search, provider, owner, language, topic, team, sort, order, show_forks, show_archived, show_private |
Shape C: Identity provider
Section titled “Shape C: Identity provider”Used by the user directory endpoints. The parameter names mirror upstream IdP conventions (Zitadel, Okta), which is why this shape exists separately.
Parameters
Section titled “Parameters”GET /users (public user directory):
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
first | integer | 0 | 0+ | Offset (items to skip) |
max | integer | 100 | 1 to 200 | Items per page |
search | string | none | - | Search filter |
GET /admin/users (admin list):
| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
page | integer | 0 | 0+ | Page number (0-based) |
pageSize | integer | 100 | 1 to 200 | Items per page |
search | string | none | - | Search filter |
Response shape
Section titled “Response shape”{ "items": [ ... ], "page": { "first": 0, "max": 100, "count": 50, "hasMore": true }, "total": 250, "provider": "zitadel"}| Field | Type | Description |
|---|---|---|
page.first | integer | Offset used in this request |
page.max | integer | Page size used in this request |
page.count | integer | Items returned in this page |
page.hasMore | boolean | Whether more items exist after this page |
total | integer | Total users across all pages |
provider | string | Identity provider name (zitadel, okta) |
Example
Section titled “Example”# First pagecurl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/users?first=0&max=50"
# Next pagecurl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/users?first=50&max=50"Shape D: True opaque cursor
Section titled “Shape D: True opaque cursor”Used by /operations/resources. Unlike Shape A, the cursor here is a genuine opaque token: a base64-encoded versioned envelope containing a resource identifier. The server navigates by index seek on that ID rather than by OFFSET.
What that means for clients:
- Page-depth performance stays constant. Page 1000 is as fast as page 1.
- Pagination is stable across writes. New rows appearing or disappearing mid-browse don’t shift later results onto earlier pages.
- No random access. You can only navigate sequentially. A Previous button needs a client-side breadcrumb stack of cursors you’ve already seen.
- The cursor format may evolve. The envelope is versioned for that reason. Don’t parse it.
Parameters
Section titled “Parameters”| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
limit | integer | 50 | 1 to 200 | Items per page |
cursor | string | none | - | Opaque cursor from a previous response |
Plus the endpoint’s filter parameters: team, kind, status, search, signal, cluster, has_gitops.
Response shape
Section titled “Response shape”{ "resources": [ ... ], "next_cursor": "eyJ2IjoxLCJpZCI6InJzX2FiYzEyMyJ9", "total": 250}| Field | Type | Description |
|---|---|---|
next_cursor | string | Cursor for the next page. Empty string ("") when no more results. |
total | integer | Total matching items. Provided for a header count badge, not for computing page numbers. |
The cursor payload looks like {"v":1,"id":"rs_..."} base64-encoded. The version field reserves room for adding secondary sort keys later. Treat the whole thing as opaque.
Example
Section titled “Example”# First pagecurl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/operations/resources?limit=20&status=Degraded"
# Next page: pass the previous next_cursor verbatimcurl -H "Authorization: Bearer shp_svc_xxx" \ "https://shoehorn.example.com/api/v1/operations/resources?limit=20&status=Degraded&cursor=eyJ2IjoxLCJpZCI6InJzX2FiYzEyMyJ9"Consumer pattern
Section titled “Consumer pattern”loop: call API with current cursor (or no cursor on first call) render items if next_cursor != "": push current cursor onto a breadcrumb stack current cursor = next_cursor else: last page reached
on user clicks Previous: pop the breadcrumb stack call API with that cursorReference implementation: web/src/routes/operations/+page.svelte in the platform repo.
Endpoints using Shape D
Section titled “Endpoints using Shape D”| Endpoint | Extra filters |
|---|---|
GET /operations/resources | cluster, team, kind, status, search, signal, has_gitops |
Limit/offset endpoints
Section titled “Limit/offset endpoints”Simple limit/offset parameters, total at the top level, no nested pagination object. Also a migration target onto Shape B.
Parameters
Section titled “Parameters”| Parameter | Type | Default | Range | Description |
|---|---|---|---|---|
limit | integer | 50 | 1 to 200 | Items per page |
offset | integer | 0 | 0+ | Items to skip |
Response shape
Section titled “Response shape”{ "actions": [ ... ], "total": 150, "summary": { "open": 45, "in_progress": 20, "overdue": 5 }}Endpoints
Section titled “Endpoints”| Endpoint | Extra filters |
|---|---|
GET /governance/actions | status, priority, entity_id, source_type, overdue |
Unpaginated endpoints
Section titled “Unpaginated endpoints”Some list endpoints return everything:
| Endpoint | Notes |
|---|---|
GET /teams | Returns all teams. Cached 3 minutes per tenant. |
General notes
Section titled “General notes”- Default limit: 20 for most endpoints. 50 for governance actions, operations resources, and zombies.
- Maximum limit: 100 for most endpoints. 200 for governance actions, zombies, operations resources, and user endpoints.
- Cursor values are opaque. Treat every cursor string as a token. Don’t parse it, don’t construct one. The Shape A format is a stringified offset today and will change when those endpoints migrate to Shape B. The Shape D envelope is versioned and will gain fields.
- Empty result sets return an empty array with the pagination metadata reflecting zero total items.
- Changing a filter resets pagination. Filters apply before pagination. Start from the first page (no cursor, or page 1) whenever a filter changes.
- Migration trajectory: four shapes today, two on the target end. Shape B for stable collections. Shape D for high-mutation streams. Details in ADR-0001.