ProductOSDocs

API

Pagination

How list endpoints paginate, when to use limit/offset versus the export stream, and how to walk large workspaces without hitting limits.

Every list endpoint returns paginated results. ProductOS uses limit + offset, with a separate export endpoint that returns every row of an entity in one response.

Default: limit + offset

GET /api/v1/feedback?limit=50&offset=100
ParameterDefaultMaxMeaning
limit50200Rows per page
offset0–Rows to skip
sortper-entity default–Sort field; see the entity reference
orderper-entity default–asc | desc

Response envelope:

{
  "data": [ /* up to limit rows */ ],
  "pagination": {
    "limit": 50,
    "offset": 100,
    "total": 423
  },
  "links": {
    "self": "https://productos.ca/api/v1/feedback?limit=50&offset=100",
    "next": "https://productos.ca/api/v1/feedback?limit=50&offset=150",
    "prev": "https://productos.ca/api/v1/feedback?limit=50&offset=50"
  }
}

pagination.total is the total matching rows for your filter, useful for "Showing 51–100 of 423" UIs.

Trashed rows and drafts are left out of list responses unless you pass include_deleted=true or include_drafts=true.

When limit/offset gets slow

OFFSET is O(rows skipped) at the database level. On workspaces with >10k rows of a single entity, paginating past offset ~10k starts to feel slow (>500ms). Two options:

Filter your way down

The easier fix: combine with filters. GET /api/v1/feedback?status=new&offset=5000 is much faster than GET /api/v1/feedback?offset=5000 because the index narrows the scan.

Use the export endpoint

For full-dataset exports (analytics, backups, migrations), use the export endpoint:

GET /api/v1/{entity}/export?format=json

It returns every row of the entity in one response, oldest first, including drafts and trashed rows (check deleted_at). format=json returns { "data": [...], "count": n }; format=csv (the default) returns a file download:

curl 'https://productos.ca/api/v1/feedback/export?format=csv' \
  -H "Authorization: Bearer $TOKEN" \
  -o feedback.csv

Exports have their own rate bucket, 6 per minute per user, separate from list reads.

Sort stability

The default sort field varies per entity (created_at for most; votes for feedback, date for releases). Rows are ordered by the one field you pick, with no automatic tiebreaker, so when many rows share a value (say votes = 5) the same offset=50 can land on different rows between calls and you'd see duplicates or gaps. For a stable walk, sort by a unique field such as id_number or created_at.

Filtering

Every entity lists its filterable fields on its reference page (for example feedback). Repeat a filter to OR multiple values (a comma-separated value is matched literally, not split):

GET /api/v1/feedback?status=new&status=under-review

Filters AND across different fields, OR within the same field. There's no support for negation or range filters via the URL syntax, use the export endpoint + downstream filtering for those cases.

?q=substring runs a case-insensitive substring match across the entity's searchable fields. It's a LIKE %term% query, not full-text, fine for short queries on workspaces up to ~100k rows. Semantic search across all entities (POST /api/ai/search) is available to the in-app session only, not to API tokens; the MCP server offers search_workspace for connected clients.

Junction expansion

For entities with junction tables, request inline expansion to avoid N+1:

GET /api/v1/feedback?expand=themes,features&limit=50

Name any of the entity's junctions, comma-separated; unknown names are ignored. Expansion counts as a slightly larger query but one request against the rate limit, not one per row.

Next

Last reviewed 2026-09-22