APIv3 is the current version of the Legalsense REST API and the one all integrations should build against. It returns JSON, uses standard HTTP verbs, and applies the same conventions across every endpoint. This article describes those shared conventions.
This article assumes you are comfortable with REST APIs. It is written both for integrators starting on APIv3 and for those migrating away from APIv1 or APIv2, which are now deprecated. APIv3 exposes more data than the older versions, through more endpoints and more fields.
APIv3 is under active development. We try to avoid breaking changes, but the API does move: each release is announced in the monthly API changelog. Integrators are expected to follow it and adjust their integration when a release calls for it. Partner and testing installations are upgraded a few days ahead of customer installations, so you can verify your integration against a new release before it reaches the customers you support. Larger or required breaking changes are typically announced at least one month before they land on customer installations.
The full per-endpoint reference lives in the Swagger documentation on your installation, at https://<installation>.legalsense.nl/api/v3/doc/swagger/. Integrators should carefully read the documentation for each endpoint they want to use, as these will explain exceptions and notable behavior when applicable.
Getting started
Base URL and authentication
Every request acts as a specific Legalsense user and carries that user's permissions. This is the same identity model as the web application. The account setups integrators typically use are described under Permissions below.
Each installation has its own base URL, https://<installation>.legalsense.nl, and all v3 endpoints sit under the /api/v3/ prefix.
Authentication has two layers. The first identifies your integration. Legalsense issues an X-Legalsense-Api-Client-Identifier and an X-Legalsense-Api-Client-Token when an integration, partner, or testing agreement is set up. Legalsense issues the identifier and token to the installation's Legalsense administrator or technical contact when the integration is provisioned. These identify your client, are valid on every Legalsense installation, so one pair works across all the customers you integrate with, and must be sent on every request. On their own they grant no access to any customer's data.
The second layer is a user session, established once per installation. Log in by posting credentials together with your client headers:
POST https://<installation>.legalsense.nl/api/v3/auth/login/
X-Legalsense-Api-Client-Identifier: <your-identifier>
X-Legalsense-Api-Client-Token: <your-token>
{ "username": "<username>", "password": "<password>" }The response returns an Authorization token. Send it with the two client headers, on every subsequent request:
Authorization: Token <auth_token>The token stays valid until you call POST /api/v3/auth/logout/ or the account's credentials change. Tokens do not expire on their own. If a request returns 401, the token is no longer valid; request a new one from the login endpoint. There is no need to log in again per request.
APIv3 supports two-factor authentication: if the account has 2FA enabled, add the six-digit TOTP code as "token" in the login body. API users generally have 2FA disabled for simpler integration, which can be done by whitelisting the IP address in the Legalsense Settings → Security.
List and detail views
Most resources are exposed as a pair of endpoints. A list endpoint, such as GET /clients/, returns many records, each carrying a minimal set of fields: id, url, and display_name, alongside a few fields specific to the resource.
A detail endpoint, GET /clients/{id}/, returns a single record with its complete set of fields.
Related collections are not nested inline on the parent record. Each is its own list endpoint, reached with a filter. To read a contact's addresses you call GET /addresses/?contact={id} rather than reading an addresses array inside the contact. Because of this, the efficient way to read data in bulk is to compose filtered list calls, not to fetch a list and then issue a detail request per row.
Extra fields
When you need more than the default fields from a list endpoint, request them with the extra_fields query parameter: GET /clients/?extra_fields=field1,field2. The available extra fields are listed in each endpoint’s description. Note that extra fields are only available on list endpoints.
Filtering and ordering
List endpoints accept query parameters to filter and order results. A filter is either an exact match, such as ?number=2024001, or a field lookup with a suffix, such as ?name__icontains=acme or ?created_date__gte=2026-01-01, and parameters can be combined. Order results with the ordering parameter, naming a field and prefixing - to reverse it: ?ordering=-created_date. The available filters and ordering fields are listed in each endpoint’s description.
Note that unsupported filters and ordering directives (typo, not implemented, etc.) are ignored and do not produce a warning. Verify during implementation that you see the expected behavior.
Send datetime strings without a timezone designator, as local (Europe/Amsterdam) time, e.g. ?modified_date__gte=2026-07-15T10:00:00. Do not include a Z suffix or a +hh:mm offset; such values are rejected with an error.
Resource status
A record is normally active. It can also be archived or deleted. By default a list endpoint returns only active records. To include archived or deleted records, use the endpoint’s status filter. The parameter name and its accepted values vary by resource, softkill_status on most, status on users, is_active on some, and are given in each endpoint’s description.
Deleted records’ modified_date is updated when they are deleted, and you can retrieve them by requesting them explicitly, for example ?softkill_status=deleted.
A record’s status is typically changed via an action endpoint instead, such as POST /users/{id}/archive/ and POST /users/{id}/unarchive/. These actions are being rolled out across APIv3 and are not yet available on every resource.
Paginated list responses
Every APIv3 list endpoint is paginated, and the response shape is identical across endpoints. A list response is an object with four keys:
{
"count": 1234,
"next": "https://<installation>.legalsense.nl/api/v3/clients/?page=2",
"previous": null,
"results": [ ... ]
}results holds the current page, count is the total across all pages, and next and previous are full URLs to the adjacent pages or null at the ends. To read a whole collection, follow next until it returns null rather than assuming a page count.
For most endpoints the default page size is 500 records, and you can request a different size with ?page_size=N up to a maximum of 1000. Requesting more than the maximum is capped to it, not rejected.
A few high-volume endpoints use lower limits: invoices, invoice sections, and invoice lines default to 20 records per page with a maximum of 50, because those records are large and expensive to serialize. Each endpoint states its own page limits in its description.
Rate limits
APIv3 does not enforce rate limits. Because your integration serves customers you share with Legalsense, we expect it to use the API efficiently and to run reliably. We monitor usage continuously and will contact you if we see unusual activity.
Common field patterns
Most fields behave as you would expect and are written back in the same shape they are read. Writing uses a round trip: request the resource with GET, change the fields you want, and send the whole object back with PUT. Read-only fields in the response are ignored on write. [How to transform a GET result into a valid PUT request] covers this in detail. Relations, derived values, and custom fields are written differently from how they read. Creating a record is a POST to the list endpoint and follows the same field rules as the round trip. Some fields can be set only at creation.
Standard fields
Every record carries a set of standard read-only fields. id is the numeric identifier. url is the absolute URL of the record's detail endpoint. created_date and modified_date record when the record was created and last changed, and all timestamps are in Europe/Amsterdam time. display_name is a human-readable label for the record, resolved in the request’s language.
You control the language with the Accept-Language request header; without it, the user's configured language is used. The value for the header should be a BCP 47 language tag including the region, e.g. en-us or nl-nl.
Decimal and number values are represented as strings in both directions, and ids are often written as strings as well, see below.
Relations
A field pointing to another record is returned as an object with id, url, and display_name, so you can identify and label the related record without fetching it.
For example, an invoice’s debtor reads as:
{
"id": 42,
"url": "https://<installation>.legalsense.nl/api/v3/contacts/42/",
"display_name": "Acme Ltd"
}On write, send the id alone as a string: "debtor": "42". A to-many relation is written as an array of string ids: "groups": ["3", "8"].
Coded values
Many fields hold a coded value from a fixed set (enum), such as an invoice’s status or payment_status. The response carries only the code, not a label, so a value like "status": 2 carries no meaning on its own. Important: In APIv3, the code is usually an integer, but on some endpoints it is a string. Check the endpoint’s description for the meaning and type of codes.
Derived values
Some fields take their value from a shared default unless a record overrides them. A matter’s reduction_rate is one example. These fields read as an object with three keys. internal is the override stored on the record, or null when there is none. representation is the value actually in effect: the override if set and the default otherwise. is_derived is true when the effective value comes from the default rather than an override.
To set an override, PUT the field with internal, for example: "reduction_rate": {"internal": "0.15"}. To remove an override and fall back to the default, set internal to null. The fields representation and is_derived are read-only.
Custom fields
Custom fields are installation-defined fields on select resources. They read and write as a list of objects, each with an identifier and a value, both strings: "custom_fields": [{"identifier": "case_ref", "value": "AB-123"}].
On write, send the complete list, including every custom field the resource has. The custom_fields field is required when the resource has any custom field definitions. To clear a value, keep its entry and set the value to an empty string.
Access & availability
Permissions
Integrators typically use one of three account setups. The first is a person's own Legalsense account, the same login they use in the web application; the mobile app works this way. The second is an API user created on the customer's installation, holding permits for the specific tasks the integration performs. The third is a read-only user with broad read access across the installation, used for reporting integrations.
Legalsense uses a role-based permission system, and as such permits are assigned to groups by Legalsense during the provisioning of an installation. These groups can be assigned by a customer with admin rights to specific users. Changing permissions in any way is done by Legalsense, with the customer's explicit permission.
A request from a user without the required permit is refused with an HTTP 403 or HTTP 404.
Access scoping
Every response is scoped to what the authenticated account is allowed to see. The records an endpoint returns respect that account's permissions, together with any matter-level restrictions the installation applies: Chinese wall settings, practice group settings, and matter access lists. Two accounts with different permissions can receive different result sets from the same endpoint, and a record outside an account's visibility is absent from list responses and returns 404 when requested directly.
The practical consequence for an integration: if a list returns fewer records than you expect, or a record you believe exists returns 404, the most likely cause is that the authenticated account does not have access to it, rather than the data being missing. Use an account whose permissions and matter access cover everything your integration needs, and confirm this before going live.
Feature availability
Not every feature is enabled on every installation. When a feature is disabled, its endpoints return 404 on write, the same response as a record that does not exist. Note that feature-related fields are typically still returned as read-only on GET.
Status codes
A successful request returns a 2xx status. A GET or PUT returns 200, a POST
that creates a record returns 201, and a successful delete returns 204 with an empty
body. Failures use the 4xx range and are described below.
Error handling
APIv3 reports errors with standard HTTP status codes and a JSON body. The body takes one of two shapes. An error not tied to specific fields uses a single detail key:
{ "detail": "Billed chargeables are not deletable." }A validation error is keyed by field, each field mapping to a list of messages. Messages not tied to a particular field appear under non_field_errors:
{
"hours": ["This field is required for kind=1,4"],
"non_field_errors": ["This matter cannot be archived while it has unbilled time."]
}One case falls outside these shapes: a missing or invalid X-Legalsense-Api-Client-Identifier or X-Legalsense-Api-Client-Token returns 400 in plain text, not JSON.
Error messages are human-readable text. There is no separate machine-readable error code in the response, so the error type is not available as a stable code. API validation errors are returned in English, whatever the request’s language.
An HTTP 404 from APIv3 does not distinguish between the three different underlying reasons:
The account cannot access it (permissions, Chinese wall, practice group, or access list).
The feature is not enabled.
It does not exist.
Keeping data in sync
APIv3 has no webhooks. To keep an external copy of the data current, you poll for changes. Most resources carry a modified_date, so filtering on it returns the records that changed since your last run: ?modified_date__gte=<last_sync>, combined with pagination and any other filters.
To pick up deletions, query for deleted records over the same window and merge the
result into your copy. Use the endpoint’s status filter:?softkill_status=deleted&modified_date__gte=<last_sync> on most resources, or?is_active=false&modified_date__gte=<last_sync> on resources that use the active flag.
Records deleted in bulk through administrative tools or data migrations are rare, but keep their original modified_date; a periodic full reconcile against a complete fetch is a worthwhile backstop.
Comments
0 comments
Article is closed for comments.