Skip to content

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

  1. Submit a batch: batch_id, package_sha256, mode (dry_run or commit), and the package payload, with an Idempotency-Key header equal to batch_id.
  2. Poll the batch's status while it runs.
  3. A dry_run batch that posted nothing can be rolled back; a commit batch 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 equal f"{batch_id}:{chunk_no}", not batch_id alone.
  • Ordering: the first chunk delivered for a new batch_id must be chunk_no=1; each next one must be highest_delivered + 1, or 409 chunk_out_of_order.
  • Replay: re-POSTing an already-delivered chunk_no with 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, or 422 chunk_header_mismatch). The last chunk's own POST returns 202 once it is stored and structurally valid — the actual merge and wire.validate_package run 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 fails wire.validate_package, the BATCH fails: poll GET .../batches/{batch_id} and read errors[] (these two codes are never returned directly from a POST .../batches call — only discoverable once a merge has actually run). A successful merge loads the merged package through the same, unmodified loader.
  • The internal receiving batch status maps to running on the wire. Chunk statuses (chunks[].status) are NOT mapped — they are reported as-is: pending, running, done or failed. The last chunk goes pending -> running -> done/failed as the worker merges and loads the batch, and its status only reaches done once the BATCH itself has reached done — a caller polling should treat the batch's own status as authoritative, not any single chunk's. GET .../batches/{batch_id} additionally reports chunk_count and a chunks[] array (chunk_no, tier_lo/tier_hi, status, counts, timestamps, errors) sorted by chunk_no — empty for an unchunked batch; each chunk's errors is a list of error-code strings (the full {entity, external_id, code, message} detail is on the batch's own errors[]). GET .../batches/{batch_id}/id-map accepts since_chunk and entity query parameters to page only the records a given chunk (or later) contributed.
  • finalise on a receiving batch is 409 batch_running. rollback of a receiving batch is allowed (nothing has posted yet) and marks it rolled_back with reversed_records: 0. A receiving batch with no new chunk for 24 hours is reaped to failed with code stale_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}]}. entity is one of documents / journals / contacts — the same vocabulary the id-map's entity filter uses. parents takes 1-20 entries; an empty list is 422 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 declared mime_type and the file's own magic bytes — a mismatch on either is 422 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.