Visena Documentation
Partner API

Guide

Lists, paging and sync

Every collection can be read in more than one way, and the difference is not convenience. It decides whether your copy of the data ends up complete.

Three read modes

Every route on this page has the shape covered in Making requests: /{instanceName}/api/v1/{locale}/person — singular, with no partner/ segment. The list variants hang off that same path. Pick one by the job you are doing rather than by which is quickest to write, because two of the three will silently give you an incomplete answer to the wrong question.

Mode Route Ordering Under concurrent change Use it for
Offset list GET /person the sort you asked for, indexed by offset and limit positions shift: rows can be skipped or repeated a page a person is looking at, one-off queries, anything that needs a real total
Cursor list GET /person/cursor ascending entity id, which is insertion order insert-stable: a new row cannot displace one you have not read one consistent export, walked start to finish in a single pass
Timeline GET /person/timeline ascending change time, from an inclusive since at-least-once: boundary rows repeat, none are dropped resuming an incremental sync, run after run

Not every resource offers all three. Person, Company, Project, Activity and Document have the full set; the two template resources have the plain offset list only, and a document folder tree is returned whole rather than paged. The API surface table at the foot of this page says which is which.

Offset lists

The default collection view: offset and limit into a sorted result, with a real total and RFC 8288 navigation links in the body.

GET /person?offset=0&limit=20
{
  "totalItems": 143,
  "totalPages": 8,
  "page": 0,
  "size": 20,
  "items": [ { "id": "xK9mQ2", "firstName": "Kari", … }, … ],
  "links": {
    "self":  "/acme/api/v1/en/person?offset=0&limit=20",
    "first": "/acme/api/v1/en/person?offset=0&limit=20",
    "next":  "/acme/api/v1/en/person?offset=20&limit=20",
    "last":  "/acme/api/v1/en/person?offset=140&limit=20"
  }
}

Follow the links, do not build URLs. Each link preserves the filters and the sort you sent and is a valid path against the same base URL. A missing next means you are on the last page. Person and Company lists also accept view=full, which returns the whole record for every row instead of the lean list item and caps limit at 100.

Why an offset loop can lose rows

An offset is a position, not a row. offset=20 means «skip the first twenty rows of the result as it looks at the moment I ask» — and between your first request and your second, the result can change shape.

Suppose a row that sorted before your page boundary leaves the result: it is deleted, deactivated out of your isActive filter, or edited so that it sorts later. Every row behind it now moves one position earlier. The row that was going to be first on page 2 slides up into a position page 1 has already passed, and your loop never asks for that position again, so that row is never delivered at all. Nothing in the response says so: next still points forward, and totalItems simply reads one lower than it did before. The opposite change — a row arriving before your boundary — shifts everything one position later, and page 2 hands you the last row of page 1 a second time.

A B C D E F G H 1 2 3 4 5 6 7 8 GET /person?offset=0&limit=4 A C D E F G H 1 2 3 4 5 6 7 GET /person?offset=4&limit=4 GET /person/cursor?cursor=(after D)
Two requests of an offset loop, four rows at a time. Page 1 delivered A B C D; then B leaves the result, so every row behind it moves one position earlier. offset=4 is still the same position — the dashed line — but E has slid to the left of it, and page 2 starts at F. E is never delivered, and no field in the response records that. A cursor asks for the rows after D instead of for the fifth position, so it crosses the line and picks E up.

Offset paging is right for a page a person is looking at, and for a query whose whole result you fetch in one response. It is the wrong loop for an export or a sync. A duplicate you can notice and drop; a skipped row leaves no trace in the response, in your log, or in your database — you find out months later, from a total that does not match. If the loop has to be complete, use the cursor list.

Cursor lists: one consistent export

GET /person/cursor pages by keyset over the monotonic entity id, so it walks the collection in insertion order and never counts positions. There is no total — a keyset page skips the count. In place of an offset, each response carries nextCursor and prevCursor, minted from the boundary rows of the page you just received: nextCursor from its last row, prevCursor from its first. That is precisely what PersonCursorCodec and CompanyCursorCodec encode in partner-rest-service.

GET /person/cursor?limit=100
{
  "items": [ { "id": "xK9mQ2", "firstName": "Kari", … }, … ],
  "nextCursor": "MXxhfGNyZWF0ZWR8MjAyNi0…",
  "prevCursor": "MXxifGNyZWF0ZWR8MjAyNi0…",
  "links": {
    "self": "/acme/api/v1/en/person/cursor?limit=100",
    "next": "/acme/api/v1/en/person/cursor?cursor=MXxhfGNyZWF0ZWR8MjAyNi0…&limit=100"
  }
}

A cursor is an opaque, URL-safe token. Store it and send it back verbatim; never parse, edit or construct one. Opaque is not the same as secret — the token carries no authority of its own, and what you may read is still decided by your bearer token — but a token that will not decode, or that was minted for a different sort key than the request uses, is refused with 400.

This is what makes the mode insert-stable: a row written while you are halfway through the walk is given an id above the boundary you are standing on, so it cannot push an unread row behind you. Filters narrow the result but are not bound into the cursor, so do not change them mid-walk — you would be sending a cursor from one result set into another.

  1. Start plainGET /person/cursor?limit=100, with no cursor at all.
  2. Follow links.next — it already carries the next cursor and your filters. Do not assemble it yourself.
  3. Stop when next disappears — the export is complete.

Sync semantics. This is snapshot paging — a stable traversal of the records that exist while you walk it — and not a resumable sync substrate. The entity id is allocated when a record is first written, not when that write commits, so allocation order is not commit order: if a page is served while a lower-id record is still uncommitted, that record is never delivered to this traversal, and no later page recovers it. Walk it start to finish for a one-off extract. To keep something in sync, use the timeline, whose imprecision repeats rows instead of dropping them.

Timelines: incremental sync

GET /person/timeline is the incremental-sync mechanism, and it is both time-windowed and cursor-paginated. since is an inclusive lower bound with an open upper end, and the pages inside that window are walked with the same opaque cursors as the cursor list. Timestamps are UTC instants carrying a Z offset.

Person and Company timelines let you choose which timestamp to page on, with key=created or key=modified; a cursor minted for one key is refused with 400 on the other. The rest have no choice: Project and Document page on the coalesced modified-or-created timestamp, and Activity on last modification, falling back to creation.

GET /person/timeline
# First run: one full export via /person/cursor, walked start to finish.
# Remember the highest "modified" value you saw. That is your watermark.

# Every run after that: ask for everything modified since a moment
# slightly behind the watermark, so the window overlaps.
curl "https://api.visena.example/acme/api/v1/en/person/timeline?key=modified&since=2026-08-20T01:59:00Z" \
  -H "Authorization: Bearer $TOKEN"

# Walk links.next to the end. De-duplicate by person id, apply the
# changes, then store the new watermark.

Delivery is at-least-once, so de-duplicate by id. The seek timestamp is read back at millisecond precision from a column stored at microsecond precision, so a record whose key value carries sub-millisecond digits can arrive again on the following page. The imprecision repeats rows and never omits them, and that is what makes an overlapping re-scan trustworthy: resume by re-issuing the request with since set slightly behind the last value you observed. The lower bound is inclusive, which is what makes that overlap expressible in the first place.

No list reports a deletion. A deleted record simply stops appearing — on the offset list, the cursor list and the timeline alike — and a GDPR erasure reaches a change consumer as an ordinary update rather than as a delete. Reconciling disappearances is your side's work: for records that means a periodic full walk of the cursor list, diffed against what you hold. The document archive is the one exception, and it is next.

The archive's own change feed

Documents have the three modes like everything else, and one more: GET /document/changes, a feed of recorded document and folder change events. It is the only partner read path on which a deletion is observable, and it is what the landing page means by nightly change feeds into your reporting.

It differs from a timeline in three ways worth knowing before you build on it. It is owner-scoped: entityType and entityId are required, and there is no organisation-wide enumeration. It is paged forward only: you pass the nextCursor from the previous page, and there is neither a backward cursor nor a links.prev. And it is ordered by recorded insertion sequence, which the changed timestamp on each event is not monotonic with — changed is a wall-clock moment on some lanes and a caller-supplied creation time on others, so it is never a resumption watermark. Resume by cursor, and only by cursor. There is no total either, and none is planned: the journal is never pruned, so counting it would mean a sequential scan of an ever-growing table on every request.

GET /document/changes
# Owner-scoped: entityType and entityId are required, not optional.
curl "https://api.visena.example/acme/api/v1/en/document/changes?entityType=Company&entityId=bQ4wR8&limit=200" \
  -H "Authorization: Bearer $TOKEN"

# Resume with nextCursor from the previous page — never with "changed".

A complete picture of the archive needs three sources, not one. The document timeline is upsert-only: it reports creation, archival and renaming, because renaming is the only partner-reachable write the platform stamps modified on. Folder moves, content replacement and description-only patches leave the row in place and go unreported there. Deletions appear only on /document/changes. So it is the timeline for upserts, /document/changes for deletions, and a periodic per-owner snapshot diff for placement, content and description changes. That third source is your side's obligation, and nothing in the API will remind you of it.

The feed also publishes its own completeness limits — which combined writes record only one event, which changes record nothing, and why a project's feed does not cover its sub-projects. Read them on the Document changes reference before you poll it.

Filters and sorting

Filters are ordinary query parameters and combine freely with any of the read modes. The links in each response carry them forward for you, which is the other reason to follow the links rather than rebuild them.

Resource Filters Sort keys
person query (name, email and employee number) · isActive · companyId · groupId · employeeNumber · externalReference · email created · modified · lastName
company query (name) · isActive · ownerGroupId · orgNumber · companyNumber · externalReference created · modified · name
company-template isActive · shouldBeMonitored fixed: name ascending
project isActive · companyId created · modified · name
project-template isActive · templateTypeId
activity projectId · companyId · responsibleId · statusId · isActive · query · dueBefore created · modified · name · dueDate
document entityType + entityId (required) · query name · created · modified · modifiedOrCreated

Two things to hold on to. Document lists are always scoped to one owning record, so entityType and entityId are required rather than optional — there is no organisation-wide enumeration of documents. And company templates are ordered by name ascending, case-insensitively, with no sort parameter at all, because name is the only orderable field upstream.

The cursor and timeline variants accept the same filters as their offset sibling but no sort: their order is the paging key, and that is the whole point of them.

The API surface

Nine resources, the read modes each one offers, and what you can do with it. The per-endpoint parameters, schemas and status codes are in the reference.

Resource Path Read modes What you can do
Person /person offset · cursor · timeline Full CRUD · duplicate probe · planned-deletion date · GDPR erasure, which is feature-gated per instance and answers 202 on enqueue.
Company /company offset · cursor · timeline Full CRUD · duplicate probe — a company number or organisation number already in use answers 409 · delete deactivates by default, mode=delete is the hard one.
Company template /company-template offset only Read-only: list and fetch.
Project /project offset · cursor · timeline Full CRUD; create builds from a templateId, and delete closes rather than removes.
Project template /project-template offset only Read-only: list and fetch. Templates decide which projects you can create.
Activity /activity offset · cursor · timeline Full CRUD · always belongs to a project · delete closes by default through the reversible quick-close, mode=delete removes · status is read-only in write bodies.
Document /document offset · cursor · timeline · changes Multipart upload and download · metadata edit · move between folders · always attached to an owning record. Every list is owner-scoped.
Document folder /document/folder the whole tree, unpaged The owner's folder tree as a flat list, parents before children: create, rename, move, delete (a cascade beyond the subtree must be acknowledged, else 409) · zip download of a subtree.
Document changes /document/changes forward-only cursor Read-only feed of recorded document and folder changes. The only read path on which a deletion is visible.

Every resource follows the same conventions — masked ids, a Location header on create, merge-patch on PATCH, multipart only for document content — and those are covered in Making requests. Every failure is an RFC 9457 problem document, covered in Errors and troubleshooting.