Skip to content

Changelog

Reverse-chronological. Entries are grouped Added / Changed / Deprecated / Removed / Fixed. A Deprecated entry always carries a sunset: date and names the deprecated route or field — see Versioning.

Subscribe via Atom feed — one entry per dated section below.

2026-09-26

Fixed

  • A Migration API CONVERSION journal line's fx_amount — the foreign-currency face of a foreign bank account's opening balance, sent alongside fx_currency — is now stored exactly. It was previously read as a Money object instead of the documented bare decimal string, so a foreign-currency opening balance either failed to load or landed with the wrong minor-unit amount. A malformed, currency-mismatched or missing fx_amount on that journal now fails the whole batch with conversion_fx_invalid instead of an opaque internal_error.
  • Every OTHER journal line's fx_amount (a manual or bank-transaction journal) is now stored too — it was previously accepted but silently discarded, so it had no effect. Stricter validation comes with it: a Money-object, over-scale, bank-currency-mismatched, or (outside a CONVERSION bank line) negative fx_amount now fails that ONE journal with its own code (invalid_fx_amount, fx_amount_missing, fx_currency_missing, fx_currency_bank_mismatch, amount_over_scale, invalid_amount) — every other journal in the same batch still posts. A journal line's fx face is kept only on a foreign bank leg or a non-monetary leg (an expense or income account). A non-bank monetary leg (a director's loan, VAT control, a receivable/payable, ...) never carries one from a journal line — those accounts' foreign balances are still correctly held and revalued when they come from a document or payment (an invoice, bill, or their payment), exactly as before; this only closes a gap where an arbitrary journal LINE could otherwise stamp a second, spurious foreign balance onto one. Dropping a face that landed on a receivable/payable account specifically now also emits a fx_face_dropped_on_control warning, in case it was meant literally.
  • EUR/foreign-currency prepayment and overpayment bank legs settled through a foreign bank account now carry the same exact fx_amount too.
  • The Conversion Balances screen no longer replaces an exact foreign amount with an ECB-derived estimate when the user re-saves the grid for an unrelated reason. A foreign-currency row whose typed base value AND typed FX rate are both unchanged now carries forward its live posting's fx_currency, fx_amount and fx_rate verbatim — including a migrated row whose foreign and base balances have opposite signs, or whose foreign face is genuinely zero, neither of which has an fx_rate to type in the first place (a blank rate box always counts as unchanged there). A row whose base value the user actually edited, whose typed rate no longer matches the stored one, or a brand-new row, is still derived from the typed FX rate as before. The page itself never auto-fills an ECB rate onto a row this applies to, so a re-save can't silently turn "no rate needed" back into a re-derivation.
  • A migration re-attempt after a successful rollback is no longer refused, including across repeated rollback/retry cycles. Rolling back a commit batch voids (never deletes) its non-carry invoices and bills, so a same-organisation retry (POST .../migration/batches, accepted with 202 and queued as usual) used to fail asynchronously with organisation_not_empty in its errors[] forever, even with a clean rolled_back batch behind it — the operator had to pass allow_non_empty: true to get past LuciBook's own leftovers. The organisation emptiness check (and GET .../migration/profile's is_empty) now ignores a record only when the batch that reversed, voided or deleted it reached rolled_back and nothing has edited that record since through a normal model save; any other document — one from a batch that is still done/running/failed/ rollback_failed, or a genuine user-entered document — still refuses the load exactly as before. Two narrow write paths that update a voided invoice/bill through a queryset .update() (a dunning-pause toggle, a supplier merge) are not yet detectable this way and are not covered by this guarantee. This now holds across more than one rollback in a row (roll back, retry, roll that back too, retry again): the check no longer relies solely on each record's current id-map entry, which only ever remembers the most recent attempt. Two re-attempt behaviours to be aware of:
  • A re-attempt after a rollback re-creates every contact the rolled-back attempt itself created (rather than reusing it; the original stays archived) — this applies whether or not that contact has a VAT number, because an archived contact is never matched again by anything, VAT number included. Only a contact the organisation already had before any migration touched it, matched by VAT number, is reused.
  • A fixed-asset register row rollback cannot undo (§B8) is reused, never duplicated, on a later attempt — if that later attempt's own package describes the asset differently (cost, accumulated depreciation, useful life, depreciation method, dates, or the asset/accumulated-depreciation/depreciation-expense accounts), the batch now carries a fixed_asset_register_diverged warning instead of silently keeping the old figures or silently overwriting them; the register row itself is left as it was.

2026-09-25

Added

  • Migration attachments (phase 3). The Migration API gained POST /api/v1/orgs/{org_id}/migration/batches/{batch_id}/attachments and its GET counterpart: a multipart/form-data upload that attaches a source file to a migrated invoice, bill, credit note, supplier credit, journal entry (including migrated bank transactions) or contact, once the batch has finished (status: done). migration:write/migration:read scope, Idempotency-Key must equal {batch_id}:attachments:{sha256}, and the endpoint is idempotent by construction — no stored reply cache. See Migration API: Attachments for the full contract, and rate limits for the per-file, per-batch and per-upload limits. New error codes: attachment_parent_ required, attachment_parent_unknown, attachment_parent_unsupported, attachment_size_mismatch, attachment_limit_exceeded, invalid_attachment_metadata, batch_not_attachable — see the error catalogue. Rolling back a batch now also unlinks every attachment it created and schedules its now-unreferenced files for deletion.
  • The attachments entry of GET .../migration/capabilities moved from NONE to PARTIAL — see the guide above for exactly what is (and is not) covered.

2026-09-24

Added

  • The Migration API gained POST /api/v1/orgs/{org_id}/migration/batches/{batch_id}/finalise: an idempotent read confirming that a commit batch's lock date and VAT lock plan were applied at load time. migration:write scope, no Idempotency-Key. New error code batch_not_finalisable (409) — see the error catalogue. The batch detail body (GET .../batches/{batch_id} and the new finalise response) now also carries vat_lock_plan.
  • The migration batch lifecycle gained three new errors[] codes a GET .../batches/{batch_id} or the finalise response can now carry: invalid_vat_return (a malformed vat_returns entry, caught before any posting starts), finalise_failed (the finalise step itself failed after a successful load — never a half-applied lock date or VAT lock), and stale_batch (a batch a background sweep closed out because its worker job was no longer live). A batch can also carry a vat_lock_plan_nothing_lockable warning when the package declared filed VAT returns but none of them ended up lockable. See the Migration API guide and the error catalogue.
  • Rate limits now opens with Limits at a glance, generated from code: max request body size and its actual failure mode, per-resource list page sizes (bills/invoices/credit notes/contacts, webhook deliveries, migration reference data and id-map), OAuth token lifetimes (access, refresh rotation and inactivity window, and authorization code), Idempotency-Key retention and max length per endpoint family, attachment limits, and migration batch/webhook ceilings.
  • Chunked migration batches (phase 2). A large migration package can now be split across several POST /api/v1/orgs/{org_id}/migration/batches requests (chunk_no/chunk_count > 1) instead of one — replacing the previous 501 chunking_not_supported response. See Migration API: Chunked batches for the full contract, and rate limits for the max-chunks and max-total-package-bytes ceilings. New error codes: chunk_out_of_order, chunk_mismatch, batch_not_receiving, chunk_header_mismatch, chunk_conflict, duplicate_record_across_chunks, unknown_package_list_key and package_too_large — see the error catalogue. GET .../batches/{batch_id} gained chunk_count and chunks[]; GET .../batches/{batch_id}/id-map gained since_chunk and entity query parameters.

Changed

  • Authenticated partner API requests against a demo company now count as activity for the 90-day inactivity purge, the same as actions recorded in the company's own activity log. See Testing with a demo company.
  • The payroll pay-run receiver (POST /api/v1/orgs/{org_id}/pay-runs) now compares the request body on an Idempotency-Key replay, matching the bills/invoices/credit-notes contract: the same key with the same body returns the original 200 replay, and the same key with a different body now returns 409 idempotency_conflict instead of silently echoing the original pay run. See Idempotency.
  • The consent screen's organisation picker now marks any organisation your app already has a live connection to for the signed-in user as already connected — still selectable (picking it again updates its granted scopes) — and defaults the selection to the first not-yet-connected organisation. See Connecting more organisations.
  • Security: client_credentials-style tokens (no user on the token) can no longer reach an ordinary authorization-code connection to any organisation. LuciMigration has moved to the normal authorization-code
  • PKCE flow (like every other partner app), and custom connections — the only shape a client_credentials token is meant to reach — are not yet available (see Custom connections), so this closes an unused path rather than changing behaviour for any shipped integration.
  • The migration API's POST /api/v1/orgs/{org_id}/migration/batches now rejects any TOP-LEVEL request-body field outside the documented set (batch_id, package_sha256, mode, package, attempt, allow_non_empty, set_lock_date, bypass_lock_actor, chunk_no, chunk_count) with 400 unknown_field, naming the offending key(s) (sorted, capped at 10, each truncated to 64 characters, with a (+N more) tail past that) instead of silently ignoring them — enforcing the additionalProperties: false the published schema already declared. The package payload's own shape is unaffected. See Migration API.
  • POST /api/v1/orgs/{org_id}/migration/batches for an unchunked batch_id replay (chunk_count unset or 1) no longer takes the per-organisation advisory lock or re-checks the concurrency cap before returning the existing batch's summary — both are now skipped whenever batch_id already names a stored batch, chunked or not, since neither applies to a request that creates no new row. The response body and status code for a replay are unchanged; only the extra lock/cap work a replay never needed is gone.

Fixed

  • An oversized /api/v1/ request body now returns 413 request_too_large in the standard partner error envelope. It previously returned Django's generic HTML 400 page: apps.core.idempotency.IdempotencyMiddleware reads the request body ahead of routing, before any view's own request_too_large handling could run.

2026-09-23

Changed

  • Token revocation (/oauth/revoke_token/) is now scoped to the owning client — a client can no longer revoke another app's tokens — and revoking a refresh token now cascades to revoke every active connection for that (application, user) across the user's orgs. See Revoke.
  • PKCE S256 is now enforced end to end: a request that omits code_challenge_method or supplies plain is refused at both the authorization and token legs, closing a gap where an omitted method defaulted to plain.
  • The per-scope role tier (payroll:*/migration:* need OWNER/ADMIN; document writes need OWNER/ADMIN/EDITOR; reads need any member) is now re-checked live on every request, not only at consent — a grantor's demotion now takes effect on their connection's very next request. See insufficient_role.
  • HTTP security headers on developer.lucibook.co.uk were hardened: HSTS, a restrictive Permissions-Policy, same-origin COOP/CORP, and a fix so that visiting a page without a trailing slash no longer redirects to an internal port number.

Fixed

  • The webhook signature verification examples on Signatures incorrectly documented the v1 value as hex-encoded; it has always been base64-encoded. The reference snippets and prose are corrected, and now also document that the secret-rotation grace window can send multiple v1= entries in one X-LuciBook-Signature header — a verifier must accept if any entry matches, not just the last one.

2026-09-22

Added

  • Connect with LuciBook: OAuth 2.0 Authorization Code + PKCE, consent per organisation, accumulating connections.
  • The flat error envelope {code, message, details, request_id} across every /api/v1/ endpoint — see Errors.
  • Rate limiting with X-RateLimit-* / X-Rate-Limit-Problem headers.
  • The developer portal — register apps, manage secrets, view connections and activity.
  • Webhooks: event subscriptions, HMAC-signed delivery (X-LuciBook-Signature), retry with backoff, and a delivery log with replay.
  • This documentation site.

Custom connections (client-credentials) are not yet available — that page describes the intended design, not a shipped feature, and will get its own Added entry once it's live.