API / Getting started
Pagination, filtering and errors
How list endpoints page and sort, the filter grammar and its limits, what an error looks like, and the conventions partial updates, custom properties and file endpoints follow.
Every list endpoint pages the same way, every filter and sort parameter uses the same grammar, and every error has the same shape. This page covers those shared mechanics once. The endpoint pages only note where an endpoint departs from them.
Pagination
List endpoints return one page at a time:
{
"records": [],
"hasMore": true,
"total": 1234,
"nextCursor": null,
"prevCursor": null
}
limit sets the page size. The default is 50 and the maximum is 200; a larger value is rejected with 400.
There are two ways to move through pages.
Offset pagination is what you get by default. Send offset (the number of records to skip) and read total. It is deprecated: total costs a count query on every page, and offset cannot exceed 50,000 — a larger value fails with INVALID_OFFSET_SIZE.
Cursor pagination is opt-in. Send cursor=0 on the first request. Each response then carries nextCursor and prevCursor (null when there is no such page), and total is null. Pass a cursor back exactly as received; one the endpoint cannot decode fails with INVALID_CURSOR. On most endpoints a cursor is a numeric offset under the hood, so the 50,000-record depth limit applies to cursors too — narrow the query with a filter rather than paging deeper.
GET /v1/general-ledger-summary is the exception: it uses a keyset cursor, has no offset parameter, rejects cursor=0, and never returns prevCursor. Omit cursor on its first request and follow nextCursor.
Pages are stable: the API appends the record id as a final tiebreaker to whatever sort you request.
Sorting
sort=field:asc,otherField:desc. The fields an endpoint accepts are listed on its sort parameter. A field that isn't listed fails with INVALID_QUERY_FIELD; a value that doesn't match the grammar fails with ILLEGAL_SORT_SYNTAX.
Filtering
filter=field:operator:value, comma-separated for several filters, which are ANDed. Operators: eq, ne, in, not_in, gt, gte, lt, lte. in and not_in take values separated by |:
filter=status:in:IN_DRAFT|SCHEDULED,createdAt:gte:2026-01-01T00:00:00Z
Rules the grammar doesn't make obvious:
- Each endpoint's
filterparameter lists the fields it accepts. Any other field fails withINVALID_QUERY_FIELD; an operator the field doesn't support fails withUNSUPPORTED_FILTER_OPERATOR. - There is no
is_nulloperator. Writefield:eq:nullorfield:ne:null.nullwith any other operator fails withILLEGAL_NULL_FILTER_OPERATOR. - There is no quoting or escaping. A value may contain letters, digits, spaces and
_ - : . / @ +, nothing else.,and|are always separators, and characters such as%,&, quotes and parentheses fail the whole request withILLEGAL_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.
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).
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.