Most paginated list endpoints share a response shape and a filter/sort grammar. This page explains those mechanics; each endpoint’s query parameters and response are the authority for its accepted fields, limits and exceptions.
Pagination
List endpoints return one page at a time:
{
"records": [],
"hasMore": true,
"total": 1234,
"nextCursor": null,
"prevCursor": null
}
limit sets the page size. The ledger-line, payable-list and other routes in the table below default to 50 and ordinarily cap requests at 200; check an endpoint’s own limit for exceptions. Ledger lines alone may allow up to 1,000 for an enabled company with a single postingDate sort. This is not a general 1,000-row allowance.
There are two ways to move through pages.
Offset pagination is the default on routes that offer both modes. Send offset (the number of records to skip); values above 50,000 fail with INVALID_OFFSET_SIZE. It is deprecated because it counts rows and can shift when data changes. The backend may cap its count near the 50,000 paging bound, while this public list envelope does not expose a cap indicator; near that depth, treat total as a possible lower bound and use the endpoint’s continuation fields rather than assuming an exact count.
Cursor pagination starts with cursor=0 on routes that offer both modes. Follow nextCursor exactly as returned, keeping the same filter and sort; do not decode or manufacture it. total is normally null in cursor mode. A cursor-shaped response does not by itself mean keyset paging: some queries fall back to numeric-offset traversal and its 50,000-depth bound. A cursor the endpoint cannot decode fails with INVALID_CURSOR.
| Public list | First cursor request | Current traversal by sort |
|---|---|---|
| Ledger transaction lines | cursor=0 |
Default and a single supported sort use keyset. A multi-sort with createdAt before a different final sort can fall back to offset. |
Invoice payables (/v1/bff/invoice-payables) |
cursor=0 |
Default and supported sort combinations use keyset in the current list implementation. |
| Users | cursor=0 |
updatedAt uses offset fallback; default and other listed sorts use keyset. |
| Sales invoices | cursor=0 |
amount, totalDue, effectiveInvoiceDate and updatedAt use offset fallback; default and eligible column sorts use keyset. Some multi-sorts can also fall back. |
| Contracts | cursor=0 |
state, company-entity and customer name/id sorts use offset fallback; default and eligible column sorts use keyset. Some multi-sorts can also fall back. |
| General-ledger summary | Omit cursor or send cursor=0 |
Dedicated account-code keyset, no offset mode and no previous cursor. Only account-code direction is sortable. |
This describes the current implementation, not a snapshot guarantee. A keyset cursor reduces positional shifts, but edits to sort values or filter membership during a multi-request read can still change what it sees; offset fallback can also skip or repeat rows after inserts or removals. Use available date/entity filters to bound large reads, check hasMore/nextCursor, and treat incomplete financial reads as incomplete. The API adds an ID tiebreaker where the query allows it; do not assume every multi-sort has the same traversal method. On sales invoices, avoid combining companyEntityId and customer in one cursor sort: the current joined-name cursor anchor can collide. Prefer a single listed sort until that source issue is resolved.
Sorting
sort=field:asc,otherField:desc (leftmost field has first priority). Use the endpoint’s sort parameter and accompanying operation notes for accepted fields; they need not match its filter fields or response fields. An unsupported field fails with INVALID_QUERY_FIELD; a value that does not match this grammar fails with ILLEGAL_SORT_SYNTAX. sort=-field is not this API’s grammar.
Filtering
filter=field:operator:value, comma-separated for several filters, which are normally ANDed. Ledger customPropertyValueId is a grouped exception; see the ledger-lines operation note. Operators: eq, ne, in, not_in, gt, gte, lt, lte. in and not_in take values separated by |:
filter=ledgerName:eq:PRIMARY,postingDate:gte:2026-09-01,lineType:in:MAIN|TAX
Rules the grammar doesn't make obvious:
- Use the endpoint’s
filterparameter and accompanying operation notes for accepted fields and any narrower operator sets. A field not supported insidefilterfails withINVALID_QUERY_FIELD; a syntactically valid operator outside a field’s allowed set fails withUNSUPPORTED_FILTER_OPERATOR. Separate shorthand keys such as?state=UNPAIDor?issuedDate[gte]=2026-09-01are not the filter parameter and may be ignored; do not rely on them or assume they produce a 400. - There is no external
is_nulloperator. On fields supporting it, the sole valuefield:eq:nullmeans IS NULL andfield:ne:nullmeans IS NOT NULL; another operator with the solenullsentinel fails withILLEGAL_NULL_FILTER_OPERATOR. A literal stringnullcannot be distinguished by quoting. Scalarne/not_infilters also include null column values; endpoint-specific array and custom-property semantics can differ. - Construct the whole parameter value, then URL-encode it once with
URLSearchParamsorcurl --get --data-urlencode. For example,filter=state:in:UNPAID|PARTIALLY_PAID,issuedDate:gte:2026-09-01becomes a single encodedfiltervalue; a literal plus sign in a value must travel as%2B. URL encoding is transport, not a way to escape the grammar after decoding. There is no quoting or backslash escape: comma and pipe remain separators, and a decoded value may contain ASCII letters, digits, spaces and_ - : . / @ +, but not%,&, quotes or parentheses (ILLEGAL_FILTER_SYNTAX). - Timestamp fields (
createdAt,updatedAt,performedAt, ...) take a full ISO-8601 instant such as2026-01-31T00:00:00Z. A bare date fails withINVALID_FILTER_VALUE, as does an enum value in the wrong case or a malformed UUID. - Boolean fields treat
true(any case) as true and anything else as false.eq, comparisons and similar scalar operators need a single distinct value;in/not_inaccept pipe-separated sets. - No fixed maximum number of
in/not_inmembers or filter/sort string length is published or enforced by the shared parser. That is not an unlimited HTTP request: endpoint validation and server/proxy request-size limits still apply. Split large ID sets into bounded reads instead of assuming a universal maximum.
For example, the public payables list accepts filter=state:in:UNPAID|PARTIALLY_PAID,issuedDate:gte:2026-09-01,vendorId:eq:<uuid> and sort=issuedDate:desc once <uuid> is replaced with an actual UUID. Its rows return currency but currency is not a filter or sort field; outstandingBalance is absent from list rows. The public ledger-lines list accepts filter=ledgerName:eq:PRIMARY,postingDate:gte:2026-09-01,lineType:eq:TAX and sort=postingDate:asc. These are supported grammar/field combinations, not a claim that any particular customer has matching rows.
Some list endpoints apply a default filter, for example GET /v1/users hides deactivated users and GET /v1/entities hides inactive entities. The endpoint page says whether sending your own filter replaces that default.
Errors
Client errors (400, 403, 404, 409) share one envelope:
{
"name": "InvalidCursorException",
"type": "BAD_REQUEST",
"errors": [
{
"type": "INVALID_CURSOR",
"message": "Invalid cursor: 'abc'",
"path": null,
"context": null
}
]
}
errors[].type is the stable key to branch on; message is for humans and may change. Validation failures can carry several entries, each with a path into the request body and a context with the identifiers involved.
401: the credential is missing, invalid or deactivated.403: the credential is valid but its roles don't allow the operation.404: the record doesn't exist or belongs to another company.429and504use a flat{ "type", "message" }body instead of the envelope:TOO_MANY_REQUESTS(see Rate limits) andGATEWAY_TIMEOUT, when a query exceeds the database's statement timeout.
Partial updates
Most PATCH bodies follow one convention for fields marked nullable in the reference: omit the field to leave it unchanged, send null to clear it. A few fields can't be cleared because the record requires them; the endpoint page names those.
Custom properties on writes
Many create and update bodies take customProperties, a list of { "groupId", "valueIds", "inlineValues" }. The same rules apply everywhere:
SINGLE_SELECTandMULTI_SELECTgroups acceptvalueIdsonly.TEXT,NUMERIC,BOOLEANandDATEgroups accept eithervalueIds(a catalogue value) orinlineValues(a literal). Sending a literal to a select group fails withCUSTOM_PROPERTY_VALUE_TYPE_MISMATCH.- Inline values are strings:
DATEasyyyy-MM-dd,NUMERICas a decimal. A value that doesn't parse fails withCUSTOM_PROPERTY_VALUE_INVALID_TYPE. - Each item replaces that group's values. An empty
valueIdsandinlineValuesclears the group; leaving the group out of the list leaves it unchanged. OmittingcustomPropertiesaltogether leaves everything unchanged. - A
valueIdfrom another group fails withCUSTOM_PROPERTY_VALUE_NOT_RECOGNIZED, the samegroupIdtwice withCUSTOM_PROPERTY_DUPLICATE_GROUPS_REQUESTED, and a group not enabled for that record type withCUSTOM_PROPERTY_GROUP_NOT_ALLOWED_FOR_OBJECT_TYPE. - A group marked required must be present with a value:
REQUIRED_CUSTOM_PROPERTY_HEADER_GROUP_MISSINGon the record,REQUIRED_CUSTOM_PROPERTY_LINE_GROUP_MISSINGon a line. Some documents enforce this only when they are posted.
Only groups your company defined are visible on GET /v1/custom-properties/groups and usable here.
Files
Endpoints that return a stored file (.../document, .../receipt) do not stream the bytes. They answer 307 Temporary Redirect to a pre-signed URL that is valid for two hours. Follow the redirect without your Light Authorization header, which the storage service rejects.
Uploads are the mirror image and take three steps:
- Call the resource's
upload-urlendpoint. The response carriesuploadUrl,keyandmetadata. PUTthe bytes touploadUrlwithin five minutes, with the sameContent-Typeyou declared and every entry ofmetadataas a request header. They are part of the signature, so the upload is rejected without them.- Tell Light about the file, or wait for it: attachments need a
POST /v1/attachmentswith thekey; card receipts are picked up automatically; expense receipts depend on the mode you chose (see Upload a receipt).
Retries and idempotency
A request that times out may still have succeeded. Endpoints that accept X-Idempotency-Key list it under Headers. Save a unique key for each intended operation before the first attempt, then reuse that key and request on retries. Do not generate a fresh key for each attempt.
Bill creation, posting, submission for approval, approval and marking as paid support this header. Their completed responses are cached for 24 hours; a replay returns the original response, not the bill's current state. In-flight or failed bill attempts that acquired a key keep it for 60 seconds from the start of the attempt. Inspect errors[].type: retry IDEMPOTENCY_IN_FLIGHT_REQUEST_IN_PROGRESS with backoff; investigate IDEMPOTENCY_VIOLATION rather than blindly retrying.
See Idempotency for the five bill routes, company/action scope, actor and input matching, partial payments and expiry. Separate line-item creation does not accept the header; inline lines can share bill creation's key. Other resources may have different defaults: credit-note creation, for example, can derive its key from documentNumber when the header is absent. The endpoint's notes are the authority.
Acting as a user
An API key authenticates as a service account that belongs to your company. It carries the roles you gave it, so any role-gated endpoint works as long as the key has the role, and its id appears as createdBy on records it creates. It is not a person, though: endpoints that act on "the current user" (creating and submitting expenses, uploading an expense or card receipt) look for a real user and fail with USER_NOT_RECOGNIZED under an API key. Call those with an OAuth token obtained by the user in question.