Pagination
Every list endpoint returns the same envelope:
{
"data": [ ... ],
"has_more": true,
"next_cursor": "MjAyNi0wMy0wMVQwMDowMDowMC4wMDBafGFiYw"
}
| Parameter | Default | Notes |
|---|---|---|
limit | 25 | Maximum 100. A larger value is clamped, not refused. |
starting_after | — | The next_cursor from the previous response. |
Keep requesting while has_more is true, passing the previous next_cursor as
starting_after. When has_more is false, next_cursor is null and you have
everything.
The cursor is opaque
It is a base64 string encoding a position. Do not parse it, construct one, or store it as if it identified a record — its contents will change, and a client that depends on their shape will break when they do.
Why not page numbers
Offset pagination is wrong for a syncing client in two ways, and both only show up in production.
It skips records. Ask for page 1, then page 2. If someone creates a record in between, every later row shifts forward by one — and the row that was last on page 1 is now first on page 2, so the row that would have been first on page 2 is never returned. Nobody notices until a reconciliation finds a person missing.
It gets slower the deeper you go. OFFSET 10000 still makes the database
walk ten thousand rows it is going to throw away.
A cursor names a position instead of counting from the start, so neither happens. The trade is that you cannot jump to "page 7" — which no integration actually needs, and every human UI should be doing differently anyway.
Incremental sync
Refetching everything nightly works until it doesn't. Most list endpoints
accept updated_since, an RFC 3339 timestamp, and return only records that
changed at or after it:
curl "https://app.operentra.com/api/v1/employees?updated_since=2026-09-01T00:00:00Z" \
-H "Authorization: Bearer $OPERENTRA_API_KEY"
Store the time you started the sync, not the time it finished, and use it as
the next run's updated_since. Overlapping slightly is free — you already have
those records and will simply write them again — while a gap loses changes
permanently.
Two limits to design around:
It tracks the record, not its children. An employee's updated_at moves
when the employee changes. It does not move when a related record elsewhere
changes. Where a child is worth syncing, it is its own resource with its own
updated_since.
Some resources have no updated_at at all, because the rows are written
once and never edited — a punch, a shift assignment. They publish no
updated_at and accept no updated_since; page them by cursor and filter on
the field that suits (punched_after, for instance). Inventing an
updated_at from the creation time would let you believe you had a change feed
that does not exist.
Deletions
A record that is deleted stops appearing in list results, which means a client mirroring the data cannot tell a deletion from a filter change.
Where a resource supports soft deletion, pass include_deleted=true and
deleted records come back carrying deleted: true. A sync that must observe
removals has to use it — otherwise a departed employee stays in your mirror
for ever.
curl "https://app.operentra.com/api/v1/employees?updated_since=2026-09-01T00:00:00Z&include_deleted=true" \
-H "Authorization: Bearer $OPERENTRA_API_KEY"
Ordering
Lists are ordered newest-first by creation. That ordering is what the cursor walks, and it is stable: a record created while you are paging appears at the start, which you have already passed, rather than shifting the rows ahead of you.