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…"
}
codeis a stable machine-readable string — switch on this, never onmessage.messageis human-readable and may change wording between releases.detailsis 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 — nodetails, norequest_id. It is under the/api/v1/prefix but is an exception to the envelope above. Its throttling responses (429, includingtoo_many_batches) do use the full platform envelope,request_idincluded. - 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
GETstill 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:
- Identify who granted the connection (
GET /api/v1/connectionstells you the org; the org's Connected Apps page shows who granted it). - 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.
- 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.
- 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. |