Errors & response conventions
The Loca.fi API predates today's REST conventions in a few places, and it is consistent about its own rules — but those rules are not always the ones you would guess. This page is the single source of truth for how responses behave platform-wide. Guides link here instead of re-explaining error handling.
Status codes at a glance
| Code | Meaning on this API |
|---|---|
| 200 | Success — and also some failures: a handful of workflow operations return 200 with the failure described in the body. See 200-despite-failure. |
| 204 | Success with no body — returned by actions that have nothing to return. |
| 400 | Validation failure and not-found. This API never returns 404 for a missing resource. |
| 401 | Not authenticated — missing/invalid token, or the wrong auth scheme (Bearer instead of Token). Body is empty. |
| 402 | Payment Required — the endpoint is gated on a module licence your organisation doesn't hold. |
| 403 | Authenticated but not permitted — you lack the module permission (e.g. no Update on ReferenceData). |
| 412 | Precondition Failed — your password has expired. Change it, then log in again. |
| 500 | Unhandled server error — notably, OData filters using functions the database layer can't translate (e.g. matchesPattern). |
Success responses
- Create / Update return the Detail DTO of the affected row (not a bare id,
not the request echoed back).
EntityVersionNoincrements on every update. - Delete returns the literal JSON boolean
true. - Actions with nothing to return respond 204 No Content.
- CSV exports are always
application/octet-streamwith attachment nameExport.csv— nevertext/csv.
Validation failures — the error array
Every service-level validation failure is HTTP 400 with a JSON array of error objects:
[
{
"ErrorCode": "InvalidOperation",
"Message": "Name is already in use",
"Field": "Name"
}
]
Two things to know:
ErrorCodefrequently collapses to"InvalidOperation"regardless of the underlying cause — theMessagefield carries the useful signal. Match onMessagetext, notErrorCode, when you need to distinguish failure causes.- In a few endpoints the descriptive text lands in
Fieldinstead ofMessage(e.g. the open-stocktake uniqueness check onPOST api/inventories/createinventory). IfMessagelooks generic, checkFieldtoo.
Not-found is 400, never 404
The platform's not-found convention differs by operation shape:
| Operation | Response for an unknown id |
|---|---|
| Get by id | 400 with an empty array body: [] |
| Update / Delete | 400 with [{"ErrorCode": "NotFound", "Message": "the resource does not exist"}] |
Practical consequence: on Get-by-id endpoints a client cannot distinguish
"doesn't exist" from a generic validation failure — treat 400 [] as not-found.
Authentication failures
The auth scheme is Authorization: Token <jwt> — a Bearer-style JWT under the
custom keyword Token, not Bearer. All of these return 401 with an empty
body:
- No
Authorizationheader - An expired or malformed token
- The right token under the wrong keyword (
Bearer <jwt>)
Authenticated-but-unpermitted is 403 (module permission missing), and licence-gated endpoints return 402 when the organisation's licence lacks the module. Expired passwords surface as 412 on login — the client must call the change-password endpoint and log in again.
OData endpoints
List endpoints accepting OData ($filter, $orderby, $top, $skip, $count)
have two failure modes of their own:
- Invalid OData syntax → a bare 400 with no body (unlike service-level 400s, there is no error array).
- Untranslatable functions → 500. The filter is composed into the SQL
query, so any OData function the database provider can't translate (e.g.
matchesPattern, which maps to a .NET regex) fails server-side. Stick toeq/ne/gt/lt/ge/le,and/or/not,contains,startswith,endswith, and date/arithmetic operators.
The 200-despite-failure family
A small family of workflow-transition operations returns HTTP 200 even when the transition fails, describing the outcome in the body instead. Always check the body, not just the status code, on:
- Order processing (
dispatchorder,receiveorder,completeorder, …) — a failed stage gate returns 200 with the order'sOrderStateunchanged and the blocking problems listed in the order's error collections. Diff the state you got back against the state you expected. - SKU updates (
updatesku) — a rejected lifecycle transition returns 200 with aLifecycleTransitionRejectedcascading-update marker, and the other field changes still commit. - Bulk upserts (
BulkUpsert*) — always 200; success/failure is per row in the response (Created/Updated/Unchanged/Failedstatuses). A batch containing failed rows does not fail the request.
Asynchronous operations
Long-running operations (order processing, stocktake resolution, and similar)
accept a NotifyOnceComplete flag. When used, the endpoint returns 200
immediately with a BackgroundTaskId in the result DTO — the work continues in
the background and the immediate response body is a placeholder, not the final
state. Poll the background-task endpoints (or subscribe to the completion events)
to learn the outcome. A dedicated Async operations concepts page covers the full
pattern.
Related
- Getting started & authentication — the login flow and token usage end-to-end.
- The per-endpoint reference pages list each operation's specific status codes.