Skip to content

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.

EndpointShapeQuery paramsResponse key
GET /entitiesA: Page-shaped cursorlimit, cursorpage
GET /searchAlimit, cursorpage
GET /entities/zombiesAlimit, cursorpage
GET /forge/moldsAlimit, cursorpagination
GET /forge/runsAlimit, cursorpagination
GET /operations/resourcesD: True opaque cursorlimit, cursortop-level
GET /repositoriesB: Page-basedpage, page_sizepagination
GET /usersC: Identity providerfirst, maxpage
GET /admin/usersCpage, pageSizepage
GET /governance/actionsLimit/offsetlimit, offsettotal (top-level)
GET /teamsUnpaginated--

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.
ParameterTypeDefaultRangeDescription
limitinteger201 to 100Items per page
cursorstringnone-Opaque cursor from a previous response

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
}
}
FieldTypeDescription
limitintegerPage size requested
totalintegerTotal matching items
nextCursorstring | nullCursor for the next page. null on the last page.
prevCursorstring | nullCursor 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
}
}
FieldTypeDescription
total_countintegerTotal matching items
next_cursorstringCursor for the next page. Empty string when done.
has_morebooleanWhether more pages exist
Terminal window
# First page
curl -H "Authorization: Bearer shp_svc_xxx" \
"https://shoehorn.example.com/api/v1/entities?limit=20"
# Next page: pass the previous nextCursor verbatim
curl -H "Authorization: Bearer shp_svc_xxx" \
"https://shoehorn.example.com/api/v1/entities?limit=20&cursor=20"
EndpointExtra filters
GET /entitiestype, lifecycle, owner, team, tags, search, source, manifestType, hasRelations
GET /searchq (required), types, tags, owner, lifecycle
GET /entities/zombiesinactiveDays, minAge, lifecycle, type (limit max 200)
GET /forge/moldsvisibility, category
GET /forge/runsmold_slug, status

Used by /repositories today. The migration target for every Shape A endpoint. Numbered pages, full metadata.

ParameterTypeDefaultRangeDescription
pageinteger11+Page number (1-based)
page_sizeinteger201 to 100Items per page
{
"repositories": [ ... ],
"pagination": {
"page": 1,
"pageSize": 20,
"totalPages": 8,
"totalItems": 150,
"hasNext": true,
"hasPrevious": false
}
}
FieldTypeDescription
pageintegerCurrent page number (1-based)
pageSizeintegerActual page size
totalPagesintegerTotal number of pages
totalItemsintegerTotal matching items
hasNextbooleanWhether a next page exists
hasPreviousbooleanWhether a previous page exists
Terminal window
# First page
curl -H "Authorization: Bearer shp_svc_xxx" \
"https://shoehorn.example.com/api/v1/repositories?page=1&page_size=20"
# Page 3
curl -H "Authorization: Bearer shp_svc_xxx" \
"https://shoehorn.example.com/api/v1/repositories?page=3&page_size=20"
EndpointExtra filters
GET /repositoriessearch, provider, owner, language, topic, team, sort, order, show_forks, show_archived, show_private

Used by the user directory endpoints. The parameter names mirror upstream IdP conventions (Zitadel, Okta), which is why this shape exists separately.

GET /users (public user directory):

ParameterTypeDefaultRangeDescription
firstinteger00+Offset (items to skip)
maxinteger1001 to 200Items per page
searchstringnone-Search filter

GET /admin/users (admin list):

ParameterTypeDefaultRangeDescription
pageinteger00+Page number (0-based)
pageSizeinteger1001 to 200Items per page
searchstringnone-Search filter
{
"items": [ ... ],
"page": {
"first": 0,
"max": 100,
"count": 50,
"hasMore": true
},
"total": 250,
"provider": "zitadel"
}
FieldTypeDescription
page.firstintegerOffset used in this request
page.maxintegerPage size used in this request
page.countintegerItems returned in this page
page.hasMorebooleanWhether more items exist after this page
totalintegerTotal users across all pages
providerstringIdentity provider name (zitadel, okta)
Terminal window
# First page
curl -H "Authorization: Bearer shp_svc_xxx" \
"https://shoehorn.example.com/api/v1/users?first=0&max=50"
# Next page
curl -H "Authorization: Bearer shp_svc_xxx" \
"https://shoehorn.example.com/api/v1/users?first=50&max=50"

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.
ParameterTypeDefaultRangeDescription
limitinteger501 to 200Items per page
cursorstringnone-Opaque cursor from a previous response

Plus the endpoint’s filter parameters: team, kind, status, search, signal, cluster, has_gitops.

{
"resources": [ ... ],
"next_cursor": "eyJ2IjoxLCJpZCI6InJzX2FiYzEyMyJ9",
"total": 250
}
FieldTypeDescription
next_cursorstringCursor for the next page. Empty string ("") when no more results.
totalintegerTotal 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.

Terminal window
# First page
curl -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 verbatim
curl -H "Authorization: Bearer shp_svc_xxx" \
"https://shoehorn.example.com/api/v1/operations/resources?limit=20&status=Degraded&cursor=eyJ2IjoxLCJpZCI6InJzX2FiYzEyMyJ9"
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 cursor

Reference implementation: web/src/routes/operations/+page.svelte in the platform repo.

EndpointExtra filters
GET /operations/resourcescluster, team, kind, status, search, signal, has_gitops

Simple limit/offset parameters, total at the top level, no nested pagination object. Also a migration target onto Shape B.

ParameterTypeDefaultRangeDescription
limitinteger501 to 200Items per page
offsetinteger00+Items to skip
{
"actions": [ ... ],
"total": 150,
"summary": {
"open": 45,
"in_progress": 20,
"overdue": 5
}
}
EndpointExtra filters
GET /governance/actionsstatus, priority, entity_id, source_type, overdue

Some list endpoints return everything:

EndpointNotes
GET /teamsReturns all teams. Cached 3 minutes per tenant.

  • 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.