New KPMG Agentic ERP, now available with KPMG in 140+ countries Read the announcement

API/Getting started

Pagination, filtering and errors

How list endpoints page and sort, the filter grammar and its limits, what an error looks like, how to retry safely, and the conventions partial updates, custom properties and file endpoints follow.

Section
Getting started
Reading
9 min read
On this page
  1. 01Pagination
  2. 02Sorting
  3. 03Filtering
  4. 04Errors
  5. 05Partial updates
  6. 06Custom properties on writes
  7. 07Files
  8. 08Retries and idempotency
  9. 09Acting as a user

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 filter parameter and accompanying operation notes for accepted fields and any narrower operator sets. A field not supported inside filter fails with INVALID_QUERY_FIELD; a syntactically valid operator outside a field’s allowed set fails with UNSUPPORTED_FILTER_OPERATOR. Separate shorthand keys such as ?state=UNPAID or ?issuedDate[gte]=2026-09-01 are not the filter parameter and may be ignored; do not rely on them or assume they produce a 400.
  • There is no external is_null operator. On fields supporting it, the sole value field:eq:null means IS NULL and field:ne:null means IS NOT NULL; another operator with the sole null sentinel fails with ILLEGAL_NULL_FILTER_OPERATOR. A literal string null cannot be distinguished by quoting. Scalar ne/not_in filters 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 URLSearchParams or curl --get --data-urlencode. For example, filter=state:in:UNPAID|PARTIALLY_PAID,issuedDate:gte:2026-09-01 becomes a single encoded filter value; 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 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. eq, comparisons and similar scalar operators need a single distinct value; in/not_in accept pipe-separated sets.
  • No fixed maximum number of in/not_in members 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.
  • 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).

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.