Migration API¶
Restricted — by arrangement
The migration API is for onboarding an organisation's historical data
from another system in bulk, not for day-to-day integration traffic.
Scopes migration:read / migration:write require the
can_use_migration_api capability flag — contact support.
Contract shape¶
The migration API's response envelope is {code, message} on error — a
narrower, contract-preserving shape kept stable for existing migration
tooling, distinct from the flat {code, message, details, request_id}
envelope every other /api/v1/ endpoint uses (see Errors).
Request bodies are flat objects, and the published OpenAPI schema marks
them additionalProperties: false — that's the stability contract: a
field name outside the documented shape isn't part of it, and new
optional fields may still be added to the schema within v1 (see
Versioning). The batch-submit endpoint parses the
request body by hand rather than through a schema validator: it reads
the fields it recognises (batch_id, package_sha256, mode,
package, attempt, allow_non_empty, set_lock_date,
bypass_lock_actor, chunk_no, chunk_count) individually — chunk_no
and chunk_count are validated (a non-integer or value below 1, or
chunk_no above chunk_count, is invalid_chunk, 422; see
Chunked batches below for chunk_count > 1)
— and it now enforces the additionalProperties: false the schema
already declared: any TOP-LEVEL key outside that list is rejected with
unknown_field, 400, naming the offending key(s) (sorted, never echoing
their values). The message lists at most 10 key names, each truncated to
64 characters with a trailing …, plus a (+N more) tail when there
were more than 10 — a caller sending a huge or malformed body cannot
inflate the error response itself. This applies to top-level keys only —
the package payload's own shape is the LuciMigration canonical
contract and is not validated here. A body that isn't a JSON object at
all (array, string, number, null) is unaffected by this check; it
still fails the existing required-field handling instead.
Batch lifecycle¶
- Submit a batch:
batch_id,package_sha256,mode(dry_runorcommit), and thepackagepayload, with anIdempotency-Keyheader equal tobatch_id. - Poll the batch's status while it runs.
- A
dry_runbatch that posted nothing can be rolled back; acommitbatch posts real ledger entries.
Concurrency is capped per organisation, counting queued, receiving
and running batches — see too_many_batches in the
error catalogue. A later chunk of an already-open batch
never counts as a new batch against this cap.
Chunked batches¶
A large migration can be split across several POST .../batches
requests instead of one (chunk_no/chunk_count > 1, phase 2,
2026-09-24). Master data (accounts, tax rates, contacts, bank accounts,
dimensions, contact redirects) is repeated identically in every chunk;
transactional entities (documents, payments, journals, fixed assets,
VAT returns, bank lines) are sliced, each record appearing in exactly
one chunk.
- Idempotency-Key: for
chunk_count > 1, the header must equalf"{batch_id}:{chunk_no}", notbatch_idalone. - Ordering: the first chunk delivered for a new
batch_idmust bechunk_no=1; each next one must behighest_delivered + 1, or409 chunk_out_of_order. - Replay: re-POSTing an already-delivered
chunk_nowith the same body is a no-op,200; with a different body,409 chunk_mismatch. - The server stores each chunk, validated structurally only (every
record carries an id; the header —
migration_id,version,source_system,conversion_date,history_from,extracted_as_at,base_currency— must match chunk 1's, or422 chunk_header_mismatch). The last chunk's own POST returns202once it is stored and structurally valid — the actual merge andwire.validate_packagerun afterwards, in the worker, not inline with that response. If the merge finds a master-data id sent with different content across chunks (chunk_conflict) or a transactional id sent in two chunks (duplicate_record_across_chunks), or the merged package failswire.validate_package, the BATCH fails: pollGET .../batches/{batch_id}and readerrors[](these two codes are never returned directly from aPOST .../batchescall — only discoverable once a merge has actually run). A successful merge loads the merged package through the same, unmodified loader. - The internal
receivingbatch status maps torunningon the wire. Chunk statuses (chunks[].status) are NOT mapped — they are reported as-is:pending,running,doneorfailed. The last chunk goespending->running->done/failedas the worker merges and loads the batch, and itsstatusonly reachesdoneonce the BATCH itself has reacheddone— a caller polling should treat the batch's ownstatusas authoritative, not any single chunk's.GET .../batches/{batch_id}additionally reportschunk_countand achunks[]array (chunk_no,tier_lo/tier_hi,status,counts, timestamps,errors) sorted bychunk_no— empty for an unchunked batch; each chunk'serrorsis a list of error-code strings (the full{entity, external_id, code, message}detail is on the batch's ownerrors[]).GET .../batches/{batch_id}/id-mapacceptssince_chunkandentityquery parameters to page only the records a given chunk (or later) contributed. finaliseon areceivingbatch is409 batch_running.rollbackof areceivingbatch is allowed (nothing has posted yet) and marks itrolled_backwithreversed_records: 0. Areceivingbatch with no new chunk for 24 hours is reaped tofailedwith codestale_batch.- See rate limits for the max-chunks and max-total-package-bytes ceilings.
Finalise¶
A commit batch finalises implicitly, inside the worker, at the end
of a successful load — chunked or not. The chunking step happens
entirely before the load: by the time the worker starts posting
anything, a chunked batch's merged package is behaving exactly like an
unchunked one, and everything below makes no distinction between them.
The organisation's lock_date is advanced to conversion_date - 1 (never
moved backwards — if it is already later, the batch warns instead of
moving it), and any VAT-filed periods the package declares are
month-locked. POST .../batches/{batch_id}/finalise takes an empty body
and no Idempotency-Key; it is an idempotent read that confirms this
happened and returns the same body GET .../batches/{batch_id} does,
including vat_lock_plan. It returns batch_running while the batch is
still queued/running/rolling back, and batch_not_finalisable for a
failed, rolled-back, or dry-run batch.
If the finalise step itself fails (for example lock_months raising on
a database error), the batch reaches status: failed with a
errors[] entry carrying code: "finalise_failed" — distinct from the
generic internal_error a failure earlier in the load produces, so an
operator can tell "the load posted but closing it out failed" from "the
load itself failed" without reading the message text. The lock date and
any VAT-plan locks are all-or-nothing: a finalise_failed batch has
moved neither.
Each entry of the package's vat_returns is validated before any
posting starts (a blank period_key, an unparseable period_start/
period_end/filed_at/received_on, or period_end before
period_start each add a code: "invalid_vat_return" finding) — a
dry_run catches this the same as a commit. A batch also carries a
vat_lock_plan_nothing_lockable warning when the package declared at
least one filed VAT return but every one of them ended up unlockable
(every month either outside the pre-conversion envelope or already
LOCKED by another source).
A batch stuck queued, running or rolling_back because its worker
crashed outright (no exception, nothing to catch) does not wait forever:
a periodic sweep closes out a queued/running batch older than its
threshold, with no live Procrastinate job behind it, as status: failed
with code: "stale_batch"; the equivalent for a stuck rolling_back
batch is status: rollback_failed with code: "stale_rollback". An
unexpected (non-FinaliseFailed) exception during rollback itself is
code: "rollback_internal_error", the rollback-side counterpart to
internal_error.
Unlocking is the ordinary LuciBook OWNER-password-reason UI flow — there
is no migration-API unlock endpoint. The one exception is rollback: it
lifts only the periods the SAME batch locked, and restores the prior
lock_date if a human has not since changed it.
Attachments¶
POST /api/v1/orgs/{org_id}/migration/batches/{batch_id}/attachments
attaches one source file (invoice PDF, receipt image, …) to a record this
batch already migrated. It is a separate multipart/form-data endpoint,
not part of the package: the package body is capped by the ordinary
request-body limit, a 7 MiB file cannot ride in it even chunked, and
fetching attachments happens during hypercare, after the load — the
bytes do not exist when the package is built.
Timing. Only a commit batch that has reached status: done
accepts attachments. queued/running/receiving/rolling_back is
409 batch_running (transient — retry once the batch settles);
failed/rolled_back/rollback_failed, or any dry run, is
409 batch_not_attachable (permanent — a dry run posts nothing, so
there is nothing yet to attach a file to).
Body. Exactly two multipart parts:
metadata— JSON text:{sha256, file_name, mime_type, size_bytes, parents: [{entity, source_id}]}.entityis one ofdocuments/journals/contacts— the same vocabulary the id-map'sentityfilter uses.parentstakes 1-20 entries; an empty list is422 attachment_parent_required— a file with no parent is never uploaded.file— the bytes. Same per-file limits as every other LuciBook attachment (see rate limits): 7 MiB, PDF/PNG/JPEG/ HEIC/WebP/TIFF only, checked against BOTH the declaredmime_typeand the file's own magic bytes — a mismatch on either is422 attachment_type_unsupported.
Each parents entry is resolved against THIS batch's own crosswalk (the
same rows GET .../batches/{batch_id}/id-map pages through). A source
record this batch never migrated, or one migrated by a different
batch, is 422 attachment_parent_unknown. A record that migrated as
something other than an invoice, bill, credit note, supplier credit,
journal entry or contact (a payment, a control-account journal leg, an
account, a tax rate, a bank account, a fixed asset) is
422 attachment_parent_unsupported. A bank transaction lands in
LuciBook as a journal entry (bank_lines support is NONE), so a
source bank-transaction file attaches under entity: "journals" to
that journal entry — the same one the JE detail page shows.
Idempotency. The Idempotency-Key header is required and must equal
{batch_id}:attachments:{sha256} exactly, or the request is
400 idempotency_key_mismatch. There is no stored reply cache —
unlike the batch-submission endpoint, this one does not need one: it is
idempotent by construction. A blob dedups on (org_id, sha256), and
each parent link dedups on (parent, blob), so replaying the exact same
request is a no-op that returns 200 with every joins[].created field
false. Re-sending the same key with a different or partial
parents list simply adds whatever links are still missing — this is
what makes a client-side retry-with-narrower-parents fallback safe after
a partial failure.
Response. 201 when the blob or any link was newly created, 200
when nothing was:
{
"sha256": "…",
"attachment_blob_id": "att_…",
"blob_created": true,
"joins": [
{"entity": "documents", "source_id": "INV-1", "lucibook_model": "invoice",
"lucibook_id": "inv_…", "created": true}
]
}
Limits. Per-batch, not per-file: at most 50,000 distinct files and
20 GiB of newly-created blob bytes may be linked to one batch (a dedup
hit against a blob the batch already attached costs nothing against
either ceiling). Exceeding either is 422 attachment_limit_exceeded.
See rate limits for the exact numbers, generated from
code.
Reconciliation. GET .../batches/{batch_id}/attachments?page= pages
through every live link this batch holds (migration:read), 1,000 rows
per page, sorted oldest-attached first, plus totals (distinct blobs,
distinct blobs this batch created, total links, and bytes newly
created). It is for client-side reconciliation and operator support —
not needed for idempotency, since the POST endpoint is already
idempotent on its own.
Rollback. Rolling back a batch unlinks every attachment link THIS
batch created (a link that already existed before the batch — e.g. the
same file attached by an earlier, different migration — is left alone)
and soft-deletes any now-unreferenced blob that this batch created (a
blob that already existed before the batch is left alone even if
unlinking makes it unreferenced, and a blob still used by another
document, or by an expense receipt, survives regardless). The bytes themselves
follow the existing 30-day soft-delete grace, same as any other deleted
LuciBook attachment. A second rollback is a no-op. An upload racing a
concurrent rollback loses the race and gets 409 batch_running, then
409 batch_not_attachable once the rollback finishes.
UI. Every attached file is visible, read-only, on the invoice, credit note, supplier credit, journal entry and contact detail pages, and (alongside files attached through the ordinary receiver endpoints) on the bill detail page.
Errors¶
The error catalogue covers every code this API can return
(invalid_mode, batch_not_found, batch_running, too_many_batches, …)
alongside every other endpoint's codes, with its HTTP status.
See the API reference for the exact request/response schemas.