Skip to content

Errors

Every error response — across every /api/v1/ endpoint except the LuciMigration API (see below) — is the same flat envelope:

{
  "code": "invalid_field",
  "message": "A field is present but fails validation.",
  "details": [{ "field": "total", "reason": "must be positive" }],
  "request_id": "b6b7…"
}
  • code is a stable machine-readable string — switch on this, never on message.
  • message is human-readable and may change wording between releases.
  • details is a list, usually of {"field": ..., "reason": ...} objects when the error is about a specific field; empty otherwise.
  • request_id — quote this in support requests. It ties the response back to the exact server-side log line.

The request_id contract

request_id is part of the flat platform error envelope shown above. It is not currently echoed back as a response header on any response, success or error; it only appears inside the JSON body of a platform-envelope error response. There is no request_id field surfaced for a successful (2xx) response today.

Two surfaces mostly use a different error shape:

  • The LuciMigration API (/api/v1/orgs/<org_id>/migration/...) returns most errors in the flatter {code, message} shape — no details, no request_id. It is under the /api/v1/ prefix but is an exception to the envelope above. Its throttling responses (429, including too_many_batches) do use the full platform envelope, request_id included.
  • The /oauth/token/ endpoint follows RFC 6749 directly: errors are {error, error_description}, the standard OAuth 2.0 shape, not LuciBook's own envelope.

When a response has no request_id, correlate a support request by timestamp and the X-Request-ID you sent (see below).

If your own request carries an X-Request-ID header, LuciBook uses it as-is as the canonical id for that request — it is not merged or checked against anything, so make sure the value you send is actually unique per request (a UUID or similarly unique value) or you'll collide with a different request's log line. That value is what shows up in request_id on a platform-envelope error response and in server-side logs. If you don't send one, LuciBook generates one.

Error-code stability

code is a promise, not just a convention: once a code exists in v1, its meaning does not change — you can safely switch/match on it in long-lived code. New codes may be added over time (as new validations and endpoints ship); treat an unrecognised code defensively (fall back to the HTTP status and message) rather than assuming the set is closed.

insufficient_role — what to do

insufficient_role is re-checked live on every request, not just at token issue, and — unlike a per-endpoint scope check — it fails the whole request, including a plain read, once either of these is true for the connection's granting OrgMember:

  • they are no longer an active, accepted member of the organisation at all, or
  • any scope in the token's effective scope set (token ∩ connection's granted scopes ∩ the app's currently allowed scopes) now exceeds what their current role may hold, per the same per-scope table below. This checks the whole effective set, not just the scope this particular request uses — a GET still fails if the token also holds a scope the grantor's current role can no longer hold, even a scope this request doesn't touch.

The live check enforces the same per-scope table consent does — there is no longer a coarser flat rule at request time. In particular, payroll:* and migration:* require the grantor to still be OWNER or ADMIN at request time, not just at the moment they granted the scope: an OWNER who granted payroll:write and is later demoted to EDITOR (which is enough to hold bills:write, but not payroll:write) has that connection start failing insufficient_role on its very next request, without anything having been revoked. The same table decides who may grant a scope in the first place, at consent:

Scope Minimum role — required both at consent AND live, on every request
bills:read, invoices:read, credit_notes:read, accounts:read, contacts:read, tax_rates:read any accepted member
bills:write, invoices:write, credit_notes:write OWNER, ADMIN, EDITOR
payroll:read, payroll:write, migration:read, migration:write OWNER, ADMIN
webhooks:manage any accepted member (gated by the app's webhooks capability instead)

It is not something your app can retry its way out of: the grantor's access changed, not your token. To recover:

  1. Identify who granted the connection (GET /api/v1/connections tells you the org; the org's Connected Apps page shows who granted it).
  2. Ask an org member whose role may grant every scope your app requests (see the consent table above) to redo the consent flow for your app.
  3. Alternatively, re-consent for a narrower token that drops the scopes the grantor's current role no longer covers. A token whose scopes are all in the "any accepted member" row of the table above only needs the grantor to still be an active member.
  4. A different user's consent creates a separate connection, bound to that user, not a repair of the old one — connections are keyed per (application, org, granting user). Your app must switch to using the token and connection that consent produces; the old connection (and its connection_id) stays broken.

Error catalogue

Every error response is the flat envelope {code, message, details, request_id} (spec §10.3). details is a list, empty unless the code says otherwise; request_id echoes the value you should quote in support requests.

Code HTTP status Meaning
account_mapping_missing 422 No chart-of-accounts mapping exists for a required pay item.
as_at_required 422 as_at=YYYY-MM-DD is required for this read.
attachment_checksum_mismatch 422 The attachment's checksum does not match what was declared.
attachment_limit_exceeded 422 This upload would take the batch over its per-batch attachment limit (distinct files or total bytes).
attachment_parent_required 422 metadata.parents must have at least one entry — a parentless file is never uploaded.
attachment_parent_unknown 422 No crosswalk row matches the given {entity, source_id} in this batch — the source record was never migrated, or was migrated by a different batch.
attachment_parent_unsupported 422 The matched record migrated as a LuciBook model that cannot carry an attachment (journal_entry_leg, payment, account, tax_rate, bank_account, fixed_asset).
attachment_size_mismatch 422 metadata.size_bytes does not match the uploaded file's actual byte length.
attachment_too_large 422 The attachment exceeds this endpoint's size limit.
attachment_type_unsupported 422 The attachment's content type is not accepted.
batch_not_attachable 409 The batch failed, was rolled back, or is a dry run — none of which ever accept attachments.
batch_not_finalisable 409 The batch failed, was rolled back, or is a dry run — none of which finalise.
batch_not_found 404 No migration batch matches the given batch_id in this organisation.
batch_not_receiving 409 A chunk was POSTed for a batch that is not (or is no longer) receiving chunks — the batch already finished chunking, or was rolled back/failed while this chunk was still in flight.
batch_running 409 The batch is still queued/running/rolling back; wait for it to finish.
bypass_actor_not_permitted 422 bypass_lock_actor must name a current OWNER or ADMIN of this organisation.
chunk_conflict 422 The same master-data id was sent with different content across chunks.
chunk_header_mismatch 422 This chunk's header (migration_id/version/source_system/conversion_date/history_from/extracted_as_at/base_currency/organisation_profile/fixed_asset_settings/source_vat_scheme/package_extensions/not_migrated_concepts) disagrees with chunk 1's.
chunk_mismatch 409 A previously delivered chunk_no was replayed with different content.
chunk_out_of_order 409 Chunks must arrive starting at 1 with no gaps (phase 2 chunking).
correction_replacement_not_posted 409 The pay run being corrected/replaced is not itself posted.
corrects_pay_run_self_reference 409 A pay run cannot correct or replace itself.
currency_mismatch 422 The pay run's currency does not match the organisation's base currency.
duplicate_bill 409 A bill with this idempotency key/reference already exists.
duplicate_credit_note 409 A credit note with this idempotency key/reference already exists.
duplicate_invoice 409 An invoice with this idempotency key/reference already exists.
duplicate_posted_period 409 A pay run for this period has already been posted.
duplicate_record_across_chunks 422 The same transactional record id was sent in more than one chunk.
fx_rate_unavailable 422 No FX rate was available at the document's ingestion date.
idempotency_conflict 409 The same idempotency key was reused with a different payload.
idempotency_key_mismatch 400 The Idempotency-Key header does not equal the value this endpoint requires: batch_id (single-request batch submission), batch_id:chunk_no (chunk_count > 1), or batch_id:attachments:sha256 (migration attachments).
idempotency_key_required 400 The Idempotency-Key header is required for this write.
inactive_account 422 A mapped account is inactive and cannot be posted to.
insufficient_role 403 The connection's granting OrgMember no longer has a role tier this scope requires — checked live on every request, never at token issue.
insufficient_scope 403 The token's scopes do not cover this endpoint.
invalid_attachment_metadata 422 The metadata multipart part is missing, is not valid JSON, is not an object, or one of its fields (other than size_bytes/checksum, which have their own dedicated codes) fails validation.
invalid_chunk 422 chunk_no/chunk_count is not an integer >= 1, chunk_no exceeds chunk_count, chunk_count exceeds the max-chunks limit, or it differs from the value stored for this batch.
invalid_document_type 422 The document_type is not one this endpoint accepts.
invalid_field 422 A field is present but fails validation (wrong type or format).
invalid_idempotency_key 400 The Idempotency-Key header is too long or contains control characters.
invalid_json 400 The request body is not valid JSON, or is a non-object JSON value.
invalid_mode 422 mode is not dry_run or commit.
invalid_modified_since 400 modified_since is not a valid ISO-8601 datetime.
invalid_pagination_params 400 limit/offset are invalid.
invalid_payload 422 The pay-run payload fails schema validation.
invalid_request 422 The request body is missing one of the required top-level fields.
invalid_side 422 A line's side is not one of the values this document type accepts.
item_inventory_account_posting 422 A pay item is mapped to an inventory-tracked account.
journal_entry_already_reversed 409 The journal entry behind this pay run was already reversed.
missing_field 422 A required field is absent from the request body.
missing_lines 422 A document was posted with no line items.
no_postable_lines 422 The pay run resolves to zero postable journal lines.
not_found 404 No resource matches the given identifier in this organisation.
nothing_to_roll_back 422 A dry-run batch posted nothing; there is nothing to reverse.
org_mismatch 403 The referenced resource belongs to a different organisation.
org_not_connected 403 This app has no active connection for the org in the URL.
package_mismatch 409 This batch_id was already accepted for a different package.
package_too_large 413 The chunked package's total size, summed across all of a batch's chunks, exceeds the limit.
pay_run_not_found 404 No pay run matches the given identifier in this organisation.
pay_run_not_postable 409 The pay run is not in a state this action can be applied to.
period_locked 409 The accounting period is locked; the write is refused.
rate_limited 429 The request exceeded a rate-limit budget; see the Retry-After header.
request_too_large 413 The request body exceeds this server's request size limit.
sum_mismatch 422 The pay run's declared totals do not sum from its lines.
tax_rate_not_configured 422 A referenced tax rate is not configured for this org.
too_many_batches 429 The organisation already has the maximum concurrent running batches.
totals_mismatch 422 The document total does not equal the sum of its lines.
unknown_field 400 The request body has a top-level field this LuciBook does not accept.
unknown_package_list_key 422 The package has a non-empty top-level list this LuciBook has no chunk-merge rule for.