Light APIv1.0.0

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 filter parameter lists the fields it accepts. Any other field fails with INVALID_QUERY_FIELD; an operator the field doesn't support fails with UNSUPPORTED_FILTER_OPERATOR.
  • There is no is_null operator. Write field:eq:null or field:ne:null. null with any other operator fails with ILLEGAL_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 with ILLEGAL_FILTER_SYNTAX.
  • Timestamp fields (createdAt, updatedAt, performedAt, ...) take a full ISO-8601 instant such as 2026-01-31T00:00:00Z. A bare date fails with INVALID_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.
  • 429 and 504 use a flat { "type", "message" } body instead of the envelope: TOO_MANY_REQUESTS (see Rate limits) and GATEWAY_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_SELECT and MULTI_SELECT groups accept valueIds only. TEXT, NUMERIC, BOOLEAN and DATE groups accept either valueIds (a catalogue value) or inlineValues (a literal). Sending a literal to a select group fails with CUSTOM_PROPERTY_VALUE_TYPE_MISMATCH.
  • Inline values are strings: DATE as yyyy-MM-dd, NUMERIC as a decimal. A value that doesn't parse fails with CUSTOM_PROPERTY_VALUE_INVALID_TYPE.
  • Each item replaces that group's values. An empty valueIds and inlineValues clears the group; leaving the group out of the list leaves it unchanged. Omitting customProperties altogether leaves everything unchanged.
  • A valueId from another group fails with CUSTOM_PROPERTY_VALUE_NOT_RECOGNIZED, the same groupId twice with CUSTOM_PROPERTY_DUPLICATE_GROUPS_REQUESTED, and a group not enabled for that record type with CUSTOM_PROPERTY_GROUP_NOT_ALLOWED_FOR_OBJECT_TYPE.
  • A group marked required must be present with a value: REQUIRED_CUSTOM_PROPERTY_HEADER_GROUP_MISSING on the record, REQUIRED_CUSTOM_PROPERTY_LINE_GROUP_MISSING on 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:

  1. Call the resource's upload-url endpoint. The response carries uploadUrl, key and metadata.
  2. PUT the bytes to uploadUrl within five minutes, with the same Content-Type you declared and every entry of metadata as a request header. They are part of the signature, so the upload is rejected without them.
  3. Tell Light about the file, or wait for it: attachments need a POST /v1/attachments with the key; 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.