# Uku API v3 — Changelog ## 2026-10-16 — Custom-field fixes from the audit: projects on the fence, safer definitions, honest filters (UKU-1147) - **Changed: 15 lists refuse a filter they cannot apply.** A dotted key (`?custom_fields.x=`, `?status.eq=`) on members, projects, suppliers, topics, notes, folders, documents, attachments, taxes, digitizations, client groups, content templates, workflow roles, custom fields or folder templates is `400 UNKNOWN_FILTER_FIELD` (`details.unknown`) instead of a `200` with the whole unfiltered list. A few other lists (contracts, products, monitors, activities, webhooks, …) still ignore an unknown dotted key. - **Changed: projects use the same custom-field checks as tasks and clients.** `POST` / `PATCH /projects` ignore a built-in field sent in `custom_fields` (`client_id`, `manager_id`, `name`, the dates, `color`, `status`) with a `CUSTOM_FIELD_IS_A_COLUMN` warning (`status` points at `status_text`; `reply_reference` is not settable over v3) — it used to move the project onto any client id, unchecked. Every value is checked by its type (`"3,14"` → `400`, not `500`; another firm's person id → `400`); an archived field is ignored (`CUSTOM_FIELD_ARCHIVED`), `null` clears a value. `contacts` replaces the whole contact list and its `roles` ids must be this company's contact roles (`400`). Required fields are judged on the merged project: a stored legacy value on another field no longer blocks an edit. A bare `status_action` (archive / cancel / …) no longer re-judges required fields, as in the backoffice. - **Changed: `POST /project-workflows/{id}/apply` checks `custom_fields` and `status` before the job starts.** `custom_fields.client_id` → `400`; `manager_id` must be a member of this company; dates `YYYY-MM-DD`; `status` (the top-level argument or the key) must be one of the company's project statuses — an off-list top-level `status` is refused `400`; other keys go through the project value rules. Ignored keys come back in the new `warnings[]` of the `202` body. A template task's `date_rule.date_field` must name a project date (`start_date` / `event_date` / `end_date` or a date-type field of the template's project type), else `400`. - **Changed: `PATCH /custom-fields/{id}` with `status: "inactive"` follows DELETE's refusals** — a built-in field `403 CUSTOM_FIELD_NOT_DELETABLE`, a field with stored values `409 CUSTOM_FIELD_IN_USE` (no `force`). Restoring (`active`) is unchanged. The `403` message now says "cannot be deleted / archived". - **Changed: option rules on custom-field definitions.** Codes match case-insensitively: removing an option records still hold under another case is `409 CUSTOM_FIELD_OPTION_IN_USE` (the message lists the codes), now also for project fields and the project / client status lists. Blank or case-duplicate option codes, and `options` on a non-select field, → `400` (an unchanged echo passes); extra keys stored on a choice are kept; a `title` is trimmed and a blank one is `400`. - **Changed: `POST /custom-fields` with `entity_type` `project` or `supplier` → `400`** (those fields are set up per type in Uku; they were stored where nothing showed them). `PATCH` / `DELETE` of a `supplier.` field now answers `404` (it committed, then `500`). Long titles keep a unique minted `name`. - **Changed: task engine keys are checked on `PATCH` / `POST /tasks`.** `workflow_date_rule` must be a date rule object whose `date_field` names a date of the task's project type, `assignees_by_field` a list of strings, `cp_welcome` only a clear, `0` or its unchanged value (`1` is refused — the portal invite sets it) — `400` naming `custom_fields.` (a bad rule used to make every save of the project fail). **A billable `number` / `price` task field refuses values beyond ±999,999,999** (`400`; a larger stored value echoed back passes). A task create no longer stores `null` / `""` keys. - **Changed: `?custom_fields.=` on `GET /clients`** refuses a Password field and a built-in field (`status`, `name`, …) with `400 UNKNOWN_FILTER_FIELD` (the message names the top-level filter), and a choice field takes the option's label or its code in any case (stored values in another case are found). - **Changed: the workflow-template push flags `fields` as destructive.** `destructive_fields` echoes `files` and `fields`; `fields` replaces the client tasks' per-client custom-field values with the template's. **New: `custom_fields` on each template task** (`ClientTemplateTaskOut`, additive) — its stored values, engine keys left out. - **Fixed: simultaneous edits** of one client, contact, supplier, task, project or template task no longer lose a custom-field key — the record is locked while its values are merged. - **Fixed: a Password field's value is no longer returned** on `/clients` or `/members` after the field was force-deleted, and `GET /activities/{id}` masks ciphertext inside a changed `fields` dict. - **Docs:** `CUSTOM_FIELD_BILLABLE_OFF` says to turn Billable on in Uku (not settable over the API); `PATCH /members/{id}` needs the Manage-Account right for every key and takes no `custom_fields`. ## 2026-10-16 — Files and documents: deletes, shared copies, a busy API (UKU-1148) - **Changed: `DELETE /api/v3/documents/{id}` with `mode=detach` (the default) of a folder-only document (`is_linked: false`)** now trashes its drive copy and Uku's backup on the last live reference, like `mode=delete` (a Dropbox / OneDrive / SharePoint copy uploaded before 2026-09-23 stays). Such a document has no owner to fall back to, so its detach was already a soft delete — the drive copy used to stay forever. A linked file's detach (`is_linked: true`) still never touches the drive. MCP `delete_record(resource="documents")` sends no `mode`, and its preview now says so. - **Changed: a move or rename of a document whose drive copy other live documents share** (recurrence / template copies) leaves that copy where it is, with its old name: `DRIVE_NOT_FOLLOWED` on `PATCH`, silent in `POST /documents/move`; the last document left sharing it moves it. - **New: `503 SERVER_BUSY` with `Retry-After: 5`** when the API node is at its concurrency limit and no slot frees within 10 s. The request did not run and is safe to retry. The per-node limit (`[api_v3] db_admit`) default is now 12 (was 40). - **Fixed: `contact.updated_at` no longer moves when a contact uses the client portal** — "changed since" filters return fewer, correct rows, also for contacts nested in client responses. No shape change. ## 2026-10-14 — Promoting a contract invoice records the work it bills (UKU-1138) - **Fixed: `PATCH /invoices/{id}` with `status: created` on a CONTRACT draft** (MCP `promote_invoice`) now issues it exactly as the app's Create does. The billing engine recalculates the draft from the work as it stands, records that work as billed, attributes the revenue per person and mints the number. Before, only the status and number changed: the work stayed unbilled, could be charged again on a later invoice and showed as missed billing. ⚠ The issued total can differ from the draft you read — use the response. A manual invoice is promoted as before. - **New: `409 INVOICE_NOT_PRODUCED`** — the engine no longer produces the draft (its contract is gone); regenerate the client's invoices. **`409 INVOICE_CALCULATION_ERROR`** — the draft's calculation failed (`details.errors`); issuing it would leave the failed lines out. Nothing is changed on either. - A second promote of the same draft while the first is running waits for it and answers `412`. ## 2026-10-14 — `custom_fields` MERGES on every door, and every value is checked by its field's type - **Changed: `PATCH /tasks/{id}` and `PATCH /suppliers/{id}` MERGE `custom_fields`** (as clients, contacts and workflow-template tasks already did): keys you send are set, a key sent as `null` or `""` is removed, keys you leave out are KEPT; `custom_fields: {}` or `null` changes nothing. Both used to REPLACE the whole dict. ⚠ An integration that deleted a field by leaving it out must now send it as `null`. Recurring series and their occurrences merge too. A supplier holds only its own type's fields, and its required fields are checked on the merged record — a stored value no longer blocks an unrelated rename. - **Changed: every custom-field value is checked by its field's type** on clients, contacts, tasks, suppliers and workflow-template tasks — before, only boolean / email / menu-link / drive-folder values were. `number` / `price`: a JSON number or a dot-decimal string (`"3,14"`, text, lists, `NaN` → `400`); `integer`; `text` (or a number); `note` / `article` / `password` (text); `choice_single` / `choice_multi`: option codes, matched case-insensitively and stored as the option's own code; `date`: `YYYY-MM-DD` (optionally ` HH:MM:SS`); `url`: `http`/`https`; `user_single` / `user_multi`: a colleague's or team's id in this firm; `supplier.`: a supplier of that type. A bad value → `400 VALIDATION_ERROR` naming `custom_fields.`, nothing written. A value identical to the one stored is never refused. ⚠ Newly refused: a choice LABEL instead of its code, comma decimals, another firm's person id. - **New warnings:** `CUSTOM_FIELD_ARCHIVED` (a value for an archived field is ignored; `null` still clears a stored one) and `CUSTOM_FIELD_NOT_WRITABLE` (a supplier's `contacts` / sub-suppliers, written through their own door, named in the message). Contact `rights` sent as a custom field now points at `portal_access`. ## 2026-10-14 — A drive that asks Uku to slow down: `503` + `Retry-After`; `POST /clients/{id}/documents-folder` answers `503 DRIVE_BUSY` - **New: `Retry-After` (seconds) on `503 ATTACHMENT_UNAVAILABLE`** when the firm's cloud drive rate-limited a document / attachment download (`GET /documents/{id}/download`, `GET /attachments/{id}/download`). The firm's drive is paused for that time — a call made sooner is refused without reaching the drive — so wait it out, then retry. Additive: the status and code are unchanged. - **Changed: `POST /clients/{id}/documents-folder` answers `503 DRIVE_BUSY` (with `Retry-After`) when the drive asked Uku to slow down** — it was `400 FOLDER_PROVISION_FAILED`, which looks permanent. Every other drive refusal (expired login, Dropbox team space, permission) stays `400 FOLDER_PROVISION_FAILED`. ⚠ An integration that treats every 4xx as final and every 5xx as retryable now retries this case, as intended. ## 2026-10-09 — Password custom fields can be created (`field_type: "password"`) - **`POST /custom-fields` accepts `field_type: "password"`** on `client` and `account_member` (the two the app offers it on); any other `entity_type` → `400 VALIDATION_ERROR` naming `field_type`. A client's value is written as before (`custom_fields.` on `PATCH /clients/{id}`), stored encrypted, never returned on reads; a member's value is set in the app (`/members` takes no `custom_fields`). ## 2026-10-09 — Client portal menu links take `{{client.}}` placeholders - **A menu link's `link` may carry placeholders** — in a client's `custom_fields.cp_menu_links` (and the firm's own list in the app): `{{client.}}` for the firm's Text / Password client fields, plus `{{client.id}}`, `{{client.reg_code}}`, `{{client.ext_client_id}}`. Anywhere in the link, or the whole link. A link with placeholders is checked only for unknown keys (`400 VALIDATION_ERROR` naming `custom_fields.cp_menu_links..link`); one without stays an `http`/`https` URL. Each client's contact sees the link filled with that client's values — so `PATCH /clients/{id}` with the field's value is how an integration sets each client's link. A client whose value is empty, or whose filled link is not `http`/`https`, does not see it. ## 2026-10-07 — Clients carry their accounting-vendor links (`vendor_ids`), readable and writable - **New on every client: `vendor_ids`** — the client's customer id in each accounting vendor, as invoice exports read it (`{"quickbooks": "123"}`; `{}` when none). - **Writable on `POST /clients` and `PATCH /clients/{id}`** with the same rights as any client edit; the vendor need not be connected. Vendors: `quickbooks`, `xero`, `economic`, `tripletex`, `fortnox`, `directo`; a `null` value unlinks. `merit_aktiva` / `smart_accounts` are refused — their export matches the customer by registration code and name. New `409 VENDOR_ID_TAKEN` when another client of the firm holds the id. ⚠ Fortnox exports use the client's `ext_client_id` first when one is set. - **e-conomic exports now use the client's e-conomic link** when one is stored, before falling back to the client's import id as before. ## 2026-10-07 — ⚠ BREAKING: contract rows say what billing applies (`effective`); `topics` reads `null`, not `[]` - **New, read-only: `effective` on every contract row** (inline in `GET /contracts[/{id}]`, `GET /contracts/{id}/rows[/{row_id}]`, and the contract / row write answers): the topics, billable filter and bill-by billing actually measures the row by, each with a `*_from` source — `topics_from` `row` | `product` | `none`, `billable_filter_from` `row` | `product` | `default`, `bill_by_from` `row` | `client` | `product` | `company` | `default`. Before, a product row read `topics: []`, `billable_filter: null`, `bill_by: null` — "no restriction" — while billing used the product's topics and filter and a bill-by from the client, product or company setting. A task's own bill-at still overrides `effective.bill_by` for that task. - **⚠ BREAKING: the row's own `topics` reads `null` when the row sets none** (was `[]`). On a row billed by its product, `topics` and `billable_filter` read `null` — the engine ignores the row's own there, so showing them misled. `bill_by` still reads the row's own value. Writes are unchanged. MCP `get_contract` / `list_contracts` carry the same fields. ## 2026-09-24 — Removing a folder-only document takes client-admin; a file on a previous drive says so - **`DELETE /documents/{id}` (default `mode=detach`)** — on a document that lives only in its folder (`is_linked: false`) the detach deletes it, so it now takes **client-admin** rights, like `mode=delete`. With manage rights alone it is `403 FORBIDDEN` and nothing changes. Unfiling a task's or note's file (`is_linked: true`) still needs only manage. MCP `delete_record(resource="documents", …)` follows the same rule. - **New code on both download routes** (`GET /attachments/{id}/download`, `GET /documents/{id}/download`): `409 FILE_ON_PREVIOUS_DRIVE` — the file is on a drive the firm is no longer connected to (it switched drives) and Uku holds no copy of its own. Before, this answered `409 ATTACHMENT_PROCESSING` ("retry shortly"), which no retry could satisfy. Do not retry; a person must reconnect that drive or upload the file again. ## 2026-09-23 — A delete leaves the customer's own content on their drive No status code or schema changes; what a delete does to the firm's connected drive does. - **`DELETE /folders/{id}`** — the mirrored drive folder is trashed only when the drive shows it EMPTY (SharePoint, Dropbox). Anything in it — the customer's own files included — leaves the drive folder where it is; only Uku's rows go. Google Drive and OneDrive never trash a folder: Uku cannot see everything inside one there. Still `204` either way. If the drive folder cannot be checked (a refused login, a throttle), nothing is deleted and the answer is `400` — as a failed trash already was. - **`DELETE /documents/{id}?mode=delete`** — a copy on **Dropbox is never trashed** (it may be the customer's own identical file); Uku's backup copy still goes. Google Drive, OneDrive and SharePoint copies are trashed as before, on the last live reference. - **Correction, same day:** OneDrive can now show a folder empty too, so on `DELETE /folders/{id}` its drive folder is trashed when empty, as on SharePoint and Dropbox. Only Google Drive never trashes a folder — Uku's access there cannot see everything inside one. - **Correction, same day:** "a copy on Dropbox is never trashed" is narrower now. Uku marks the copies it uploads itself from 2026-09-23 on. On `DELETE /documents/{id}?mode=delete` a copy on Dropbox, OneDrive or SharePoint uploaded BEFORE that stays on the drive (it may be the customer's own identical file); one Uku uploaded since is trashed like any other, and a Google Drive copy always is. The same copies are left where they are on the drive by `PATCH /documents/{id}` (a `DRIVE_NOT_FOLLOWED` warning says so) and by `POST /documents/move` (silently). ## 2026-09-23 — A refusal by the firm's cloud drive is no longer a 500 **New codes on the two download routes** (`GET /attachments/{id}/download`, `GET /documents/{id}/download`) when the firm's connected drive (Google Drive, OneDrive, SharePoint, Dropbox) refuses the fetch — before, each of these answered `500 INTERNAL_ERROR`: - `409 DRIVE_RECONNECT_REQUIRED` — the firm's drive login expired or was revoked. Your key is fine; a person in the firm must reconnect the drive in Uku (Settings & Apps → Documents). Retrying will not help until then. - `409 DRIVE_REFUSED` — the drive said no for another reason (a Dropbox team space, a permission); `message` names it. - `404 NOT_FOUND` — the file was deleted or moved away on the drive itself. - `503 ATTACHMENT_UNAVAILABLE` — the drive rate-limited the fetch; retry with backoff. **`POST /clients/{id}/documents-folder`** answers the same refusals, on the client's root folder too, as the documented `400 FOLDER_PROVISION_FAILED` with the reason in `message` (before, a refusal on the root was a `500`). Every message is fixed English; the drive's own error text is never returned. ## 2026-09-21 — Documents: `q` is a literal keyword; folder templates: `create_empty_folders` is deprecated **`GET /documents?q=` now matches the text LITERALLY** — `%` and `_` are characters someone typed, not SQL wildcards. Before, `q=50%` silently matched every document the client had (read as "everything"); it now looks for a file named with a literal `50%`. The folder-scoped keyword path already worked this way; this closes the disagreement between v3's two readers. **⚠ DEPRECATED — `create_empty_folders` on a folder template is no longer per template.** Whether a client's folders reach the firm's storage without waiting for a file is now ONE FIRM-WIDE setting (backoffice, Documents → Connectors → *When client folders are created*), with no API surface. The field is still read back — every template of the firm answers the firm's value — and a save may still send it; the value is IGNORED and never an error, so an integrator round-tripping what a GET gave it keeps working. It will be removed from the schema in a later release. ## 2026-09-20 — ⚠ BREAKING: a year/month node's name needs its own token; folders are fenced by workflow role **⚠ BREAKING — a `year` or `month` template node's `name` must be its kind placeholder (`year` / `month`), empty, or a pattern carrying its own token.** `Workpapers {year}` names one folder per year; `Workpapers` alone would name every year the same and only the first would ever exist — previously stored and silently ignored, which this closes. A name that is neither is refused with `400 VALIDATION_ERROR` (the preview names it `NAME_PATTERN_WITHOUT_OWN_TOKEN`). **⚠ New: a folder may be restricted to members holding a given workflow role on that client.** Every API key acts as the person it was issued to, so a folder outside that person's reach is now **404** — on `GET /folders`, the client folder-tree, `GET /folders/{id}`, and on `GET /documents` and every document route naming a fenced folder — exactly as it is absent from that person's own backoffice tree. Company owners and admins, and a client's own Client Admins, are never restricted. A folder the person MAY reach that sits inside one they may not still appears as a pass-through container, so the path to it exists; its `document_count` rolls up only the readable subtree. **New template node kind: `inbox_files`.** Configures the client's "New files" inbox inside a template — it never creates the inbox (that happens on first tree read) and is re-injected at the root on every save. ## 2026-09-19 — ⚠ BREAKING: folder templates keep their node ids, and folders carry a visibility **A template node now keeps its `id` across a save, so a rename is a rename.** Send a node's `id` back on `PATCH /folder-templates/{id}` and that node's row — and the folder it generated on every client of the template — is renamed or re-kinded in place, with the files still in it. Omit the `id` on a node you are adding. An id the server does not recognise is treated as a new node, never an error, and a payload with no ids at all still churns nothing: a node is then matched to its stored sibling by `kind` and by folder name (case, spacing and punctuation folded), which is what every caller written before today gets. Removing one node and adding another is now a genuinely different act from renaming one — the removed node's folder stays where it is on every client that holds files in it, and the added node's folder starts empty. **⚠ BREAKING — the node kinds `notes` and `templates` are gone: sending either is now refused with `400 VALIDATION_ERROR`** (pydantic's own enum rejection — never `422`; this surface answers no request-shape refusal with FastAPI's default). They are ordinary `static` nodes carrying a routing CLAIM, so a firm can rename the folder, move it under another one, or point the claim at a folder it already has, and filing follows. In the Uku app, that folder is then `{"kind": "static", "name": "Notes"}` with a `notes` routing claim; the claim vocabulary is `notes`, `task_templates` and `tasks`. **`routing_keys` is NOT writable on this body** — sending it is refused (`extra="forbid"`), so do not send it in the example above; it is configured in the backoffice template editor only — the folders themselves were migrated, so a template that carried the old kinds already reads back as static nodes with their claims. **⚠ BREAKING — the `name` limit on a template node is 100 characters, and it is judged on the RENDERED name.** It read 255 before, which was the node column rather than the folder column: a longer name stored fine and was then silently truncated to 100 when the client's folder was created, where it could collide with a sibling and lose. A name whose rendered form (tokens replaced) exceeds 100 is refused with `400 VALIDATION_ERROR`, never `422` — the specific reason (`NAME_TOO_LONG`) is visible only through `POST /folder-templates/preview`'s `errors[].code`, not on the save itself. (The raw TYPED field still accepts up to 255 characters, the storage column's width, because a token like `{month}` shrinks a great deal on render — see the field's own description.) **`POST /folders` creates the folder `private`,** never inheriting from its parent — a caller passing an explicit `parent_id` under a client-facing root would otherwise publish the folder to that client's portal the instant it existed. **New on `FolderOut`: `visibility`, read-only** (`inherit` · `public` · `private`) — the STORED value, not a pre-resolved one: `inherit` means take the nearest ancestor's answer (no public ancestor is private), and this surface hands that back verbatim rather than walking the chain for you. It is authored in the Uku app, on the template node or on the folder itself; this surface reports it and does not set it. **New: `revision` on a template.** Bumped on every save. Send it back as `revision` on a `PATCH` and a save whose base has moved is refused with `409` carrying the current tree, instead of replacing the other edit; omit it and no check is made, so a caller written before this field keeps working. **New routes:** - `POST /folder-templates/preview` and `POST /folder-templates/{id}/preview` — what the same body on a save WOULD do, writing nothing: the refusals it would raise, the node changes, how many clients it touches, the per-client folder counts (created / adopted / pruned / renamed / stranded) and the warnings worth seeing first. - `POST /folder-templates/{id}/resync` — re-queue the folder pass for every client of the template, whether or not the tree changed. **Unchanged and deliberate: the per-node SETTINGS are not on this schema.** Visibility, the contact-role and member-role fences, the portal menu flag and the routing claims are all authored in the Uku app. An ABSENT setting is read as UNCHANGED, so a whole-tree `PATCH` from an integrator written before any of this existed rewrites the structure and leaves every permission on it intact. ## 2026-09-18 — `GET /search`: a personal key finds only what its person may open; never a template **A person-scoped key now sees the same rows in `/search` that its person sees in the Uku app.** Contacts, suppliers and contracts were tenant-wide for a personal key — a key whose person may open 12 of a firm's 800 clients could find any client's contacts and contracts by keyword. Each category is now fenced by the rights its own read uses: `READ_CLIENT` on the client a contact hangs off, `VIEW_ACCOUNT` for suppliers, `VIEW_TASK` on a task (unchanged), for invoices and contracts the same gate as the Uku app's Billing view (an admin, or the `billing.invoices` / `billing.contracts` team right — invoices on top of the `financials` scope; a contract hit now implies `GET /contracts/{id}` would succeed), and for notes the client's `READ_CLIENT` plus the note's own permission list — a note restricted to named people is found only by them, a client manager or an admin. Integration keys are unaffected: tenant-wide as before. **A task hit is never a template.** `category=task` no longer returns a recurrence-rule row (the series definition, not a dated task) or a master task under a `plan` client or a `workflow` project — the fence `GET /tasks` already applies to a listing. Fetch a template by its id or by `client_id` on `/tasks`, as before. ## 2026-09-17 — ⚠ BREAKING: a date FILTER shaped like a day is a day, and `.lte` now includes it **A filter value shaped like a DAY is that calendar day in the firm's timezone; a value carrying a TIME is a moment, unchanged.** This is the third axis of the task-date contract below (writes and reads already round-trip a day); the filters now agree with them. Before: `?date.gte=2026-09-15` was read as midnight UTC. A Tallinn firm's task dated "the 15th, no time" is stored at 2026-09-14 21:00 UTC, so "tasks from the 15th onwards" HID the task dated the 15th, and west of UTC the error flipped to the other end of the range. `?created_at.lte=2026-09-30` was midnight UTC at the START of the 30th and dropped the whole day; `?date=2026-09-15` meant "exactly midnight UTC" and matched nothing. Now, for a bare `YYYY-MM-DD` on any timestamp column (`created_at`, `updated_at`, `finished_at`, `completed_at`, calendar bounds, and `task.date` / `task.due_date`): - `.gte` from the START of that day · `.lt` before its start - `.gt` after the END of that day · **`.lte` THROUGH the end of that day — this is a behaviour change: `.lte` was exclusive of the day it named, it is now inclusive.** If you compensated by sending the next day (`created_at.lte=2026-10-01` to mean "through September"), you now get October 1st as well — send `2026-09-30`. - `.eq` within that day (a range, not an instant) · `.neq` outside it · `.in` the union of the days listed - On `task.date` / `task.due_date` a day-dated task (`date_only: 1`) is compared as the day it stands for in its OWN timezone, and a timed task as an instant from the firm's midnight — the split the Uku app's own dashboard makes. The firm's timezone is `GET /auth/me` → `timezone`. A full ISO-8601 datetime (`2026-09-15T14:30:00+03:00`, `…Z`, or offset-less = UTC) is byte-identical to before. The bespoke windows follow the same rule: `created_from` / `created_to` on `/activities` and `/documents` (a bare `created_to` runs through the end of its day — the old advice to "give `created_to` a time or the next day" is withdrawn), `date_from` / `date_until` on `/calendar`, and `start_from` / `start_to` on `/time-entries` (`start_to` is still "before this", so a bare day is the start of that day; the anchor moves from UTC to the firm's timezone). Those four params are typed as strings in `openapi.json` now, since a `date-time` type would have parsed the day before the server could ask whose calendar it was. Unchanged: `?sort=date`, cursors, and the `DATE` columns (`recurrence_date`, `bill_at`, `invoice_date`, invoice `due_date`, flextime and budget bounds), which never had a timezone. Unparseable values are still `422 INVALID_FILTER_VALUE`; a filter is never silently dropped. ## 2026-09-17 — ⚠ BREAKING: a task date you send as a DAY comes back as a day **`date` and `due_date` now have two shapes, and the one you send is the one you get back.** A bare `"2026-09-15"` is the CALENDAR DAY: it is stored as that day in the company's timezone, marked `date_only: 1` / `due_date_only: 1` (the same columns the Uku app's own date picker writes), and read back as `"2026-09-15"` — the same day for every reader, in every company timezone. A full ISO-8601 datetime is still a moment: flag 0, read back as UTC, unchanged. Both flags are now on the task read, and writable if you ever need to override what the shape says. Before this, every task created through the API, MCP or the CLI was stored as a raw instant with the flag at 0. A caller who said "September 15, no time" from a Tallinn firm got `"2026-09-14T21:00:00Z"` back — the right instant and the wrong answer: it reads as the 14th, it moved with the company timezone and DST, and the app showed the task with a `00:00` clock it never had. Reads that carry a task date follow the flag everywhere: `GET /tasks`, `GET /tasks/{id}`, the dashboard views, create/update responses, `GET /search` (a day-dated task is named by its day, not `"… 00:00:00"`) and the `task.created` / `task.updated` webhook payloads. **⚠ Webhook subscribers and strict parsers: `date` / `due_date` are no longer always a datetime.** A day-dated task arrives as `"2026-09-15"` in every `task.*` event and every task read, where it used to be `"2026-09-14T21:00:00Z"`. A field parsed as RFC 3339 (`time.Time`, jq's `fromdateiso8601`, a `date-time`-only generated client) fails on the day form — read `date_only` / `due_date_only` and accept both shapes. `GET /search`'s `date` likewise becomes `"2026-09-15"` for a day-dated hit, no longer `"2026-09-15 00:00:00"`. There is no version switch; the change lands for every caller at once. A cached `ETag` keeps serving the old shape until the row is next written. The request schemas now advertise both shapes too: `date` / `due_date` on `POST /tasks` and `PATCH /tasks/{id}` are `date-time | date | null` in `openapi.json`, so a client generated from the spec can send a bare day. **A day-named deadline sits at the END of its day.** `"due_date": "2026-09-20"` stores `2026-09-20 23:59:59` in the company timezone, the same edge the Uku app's picker writes — a deadline at midnight would make the task overdue for the entire day it is due. A day-named START stays at `00:00:00`. **Omitting `due_date` no longer leaves it empty — it copies `date`.** A task whose `due_date` column is NULL is missing from every dashboard and every dashboard count (the dashboard's history floor is a `due_date` comparison, and NULL fails it), while still listing under Client Tasks. The app's own writer has always defaulted `due_date = date`; this one did not, so **every task created through the API, MCP or the CLI since 2026-07-10 is affected**. New creates now mirror `date` with `has_due_date: 0`, an explicit `"due_date": null` included, and any PATCH heals an older row. Read `has_due_date` — not whether `due_date` is set — to know whether a task really has a deadline; the two fields are independent by design. On `PATCH`, an explicit `"due_date": null` REMOVES the deadline: `has_due_date` becomes 0 and the column mirrors `date` (send `has_due_date` yourself to state the flag instead). **`POST /tasks/bulk-action` with `action: "snooze"`** takes the same two shapes. A bare day used to be read as midnight UTC and then re-rendered in the company zone, which moved the day itself for a firm west of UTC — "snooze to the 15th" landed on the 14th in Los Angeles. A bare-day snooze also sets `date_only` / `due_date_only` on every task it moves (a task that was an instant would otherwise have read back as one), and a day-named `due_date` lands at the end of its day, like everywhere else. The same end-of-day edge now holds for a day-named `due_date` PATCHed onto a recurring or template task. ⚠ **Filters have NOT changed.** `?date.gte=2026-09-15` still compares UTC instants, so a day-dated task can fall outside a filter naming its own day. Use a day either side, or `recurrence_date` for occurrences, until that lands. ## 2026-09-14 — Documents: a period filter, and `is_signed` **`GET /documents` takes `created_from` / `created_to`** — ISO-8601 date/datetime, both inclusive, on when the file was added. A bare date is midnight UTC at the START of that day, so give `created_to` a time (`2026-03-31T23:59:59Z`) or the next day. Applied on the folder-scoped and the client-wide listing alike; `meta.total` counts the narrowed set. **Every document row carries `is_signed`** (0/1) — 1 only for a stamped e-signing bundle, the signed PDF/ZIP the signing worker attached to the task; the originals it was built from stay 0. Both are the backoffice Files tab's new Period filter and Signed chip, propagated. ## 2026-09-06 — Contracts: create with rows, all or nothing; a real preview **`POST /contracts` takes `rows[]`.** Every row is validated first (the same rules as `POST /contracts/{id}/rows`, including the product's tax/discount inherited when omitted) and the contract is created with all of them in one transaction, or not at all: `422 CONTRACT_ROWS_REJECTED` names each refused row by `row_index`. Row warnings ride along in `warnings[]`. **New `POST /contracts/preview`** runs exactly those checks on the same body and writes nothing — `would_create`, `rejections[]`, `rows[]` as they would be stored. The MCP `create_contract` preview is now that call, so it can no longer green-light a line the server then refuses; its "the contract exists but N lines failed, do NOT re-run" outcome is gone. ## 2026-09-06 — Contracts: apply a template to many clients **New `POST /contracts/{id}/apply`.** "Put these 30 clients on the master package" was 30 hand-built contracts, each re-sending every row: a template was a label, not a source. The route wraps the backoffice bulk "Edit Contracts" dialog — `mode` `add` (one new contract per client, rows and prices copied), `replace` (ends the clients' live contracts the day before `start_date`, re-dates one already on the template, creates where none is) and `append` (rows copied onto existing contracts, package link cleared). Up to 200 clients, synchronous, with a per-client result; `dry_run=true` returns the same plan and writes nothing. ⚠ Unlike the dialog it does NOT run the billing engine afterwards — `POST /clients/{id}/invoices` per client, or wait for the billing run; the response says so. MCP: `apply_contract_template`. ## 2026-09-06 — Tasks: the list is real work only **`GET /tasks` no longer returns rows that are not tasks.** Three populations share the `task` table and carried nothing in the row to say so: the firm's magic-button timer rows (`type=button`), a client-workflow template's master tasks (they hang off a `client.type='plan'` row) and a project-workflow template's tasks (they hang off a `project.type='workflow'` row). All three were listed and counted as work — on the development corpus 18495 template rows, 6862 timers and 19488 workflow-project rows — so a "delete every task of these clients" loop over the list reached the firm's templates and timers. Unset, the list now holds `task` and `request` rows only, excluding both engines' template rows; `meta.total` follows. `type=button` lists the timers, `client_id=` lists that template's master rows, and `GET /tasks/{id}` still reaches any row by id. A client-less task that belongs to no template is real work and stays listed. MCP `list_tasks` and `uku tasks list` inherit the same fence. ## 2026-09-06 — kpi-summary: `draft_revenue` **`GET /reports/kpi-summary` also gains a top-level `bi_state`**: `ok`, `invoicing_only`, `not_billing`, `no_rates` or `empty` — what the firm's data can support in the window. A zero utilisation on an `invoicing_only` firm is not a finding; say the firm tracks no time. **`GET /reports/kpi-summary` gains `draft_revenue`** (with the `financials` scope, like the other money keys): the net amount sitting in draft, unlocked invoices for the window. It is pending, never part of `revenue`, `operating_profit` or `billing_gap` — the number to look at before a month is closed. The same key reaches `get_bi_summary` on MCP. ## 2026-09-03 — Webhooks: the sample now matches the delivery; delivery history shows your endpoint's answer; the docs say what fires **`GET /webhooks/example?event=client.*` now returns the exact shape a live `client.created` / `client.updated` delivery carries.** It was built from the eager-nested read projection, so the sample carried `contacts[…]` and `contacts_truncated` — fields no live `client.*` delivery ever sends (the write paths project lean, and the serializer strips the never-fetched keys). A field-mapper built on the sample mapped fields that never arrived. Every other resource's sample was already right and is unchanged. **`GET /webhooks/{id}/deliveries` rows now carry `response_body`** — the first 2000 characters of what your endpoint answered, or, when nothing was sent, the reason (`blocked: …`, `refused: no signing secret`, `not delivered: subscription paused/disabled`). Additive. The delivered payload is still not in history; replay re-sends it by `event_id`. **The docs now say what fires.** A v3 event describes a change made through API v3 (REST, MCP, the CLI). A change made in the Uku app itself produces no v3 event. This was always the behaviour; `llms.txt`, `GET /webhooks/events` and the OpenAPI descriptions now state it, and every event description ends in "via API v3". Also stated, all pre-existing behaviour rather than new: events that fire while a subscription is paused or auto-disabled are not stored and cannot be replayed; deliveries are paced per company, and a delivery that waits out its cap is parked as a visible, replayable non-delivery rather than discarded; and three write paths fire nothing at all — bulk actions (`POST /tasks/bulk-action`, `POST /clients/bulk-member-action` and their jobs), workflow-template task deletes, and the multi-template Replace teardown. `POST /time-entries/bulk` is not one of them: it fires per row. **A failure inside Uku no longer reports our exception text.** `GET /webhooks/{id}/deliveries` rows whose attempt died in our own code now read "Delivery failed inside Uku; the attempt was recorded." Every other `response_body` — your endpoint's own answer, and the `blocked:` / `refused:` / `not delivered:` reasons — is unchanged and verbatim. **🔴 Fixed: every delivery announced the wrong `Host` header.** The IP-pinning adapter dials a pre-validated public IP (SSRF defence) and was supposed to keep your hostname on the HTTP layer; it did not. `http.client` derives `Host` from the connection when the request carries none, and that connection is keyed on the pinned IP — so deliveries arrived as `Host: 93.184.216.34` instead of `Host: hooks.example.com`. TLS was never affected (SNI and certificate verification always used the real hostname), but **any receiver that routes on `Host` — a shared ingress such as `hooks.zapier.com`, a reverse proxy, a multi-tenant endpoint — would have misrouted or rejected the request.** Your hostname is now sent, with the port kept when it is not the scheme default and IPv6 literals bracketed. If you built a workaround (matching on a path instead of the host, or accepting any `Host`), you can drop it; nothing else about the request changed. **Correction to the 2026-07 security entry below:** it says `DELETE /webhooks/{id}` requires an active subscription. That gate was removed on 2026-08-08 (CTO): a lapsed company must be able to switch its own integration off. `DELETE` needs the `admin` scope only; every other webhook write is still subscription-gated. ## 2026-09-02 — MCP: two tools renamed for what they are, two name shortcuts added, two tools removed **Renamed, no alias:** `search_tasks` → `list_tasks`, `search_activity` → `list_activity`. Neither was a search — both wrap a FILTERED LIST (`GET /tasks`, `GET /activities`) and need no query term, while `search_uku` (`GET /search`) is the one free-text lookup and requires one. Three tools sharing the verb "search" was a coin flip for a model choosing from names alone, which is exactly how ChatGPT and Gemini choose (they load no schema on demand). No alias is kept: the MCP server has not launched, so nothing calls the old names. **Name shortcuts:** `list_tasks(assignee="Mari")` and `list_activity(actor="Mari")` take a person NAME, the way `client` / `person` already did elsewhere — "what did Mari do last week" is one call instead of a member lookup plus the list. Ambiguous names are refused with the candidates listed, as before. **Removed:** `confirm_flextime_period` / `decline_flextime_period`. A monthly admin sign-off with no assistant demand, costing two of the ~100 tool slots clients allow per server. Period sign-off is a backoffice action (Reports → Flextime); `get_flextime_balance` stays. The REST routes are untouched. **Merged (same day, same reason — slots):** four pairs that were one REST call each became one tool each, every one keeping its predecessors' confirm tier, ETag handling and refusal texts. `update_team(person_ids=, lead_person_id=)` replaces `update_team_members` + `set_team_lead` (both were `PATCH /teams/{id}`; either half alone or both in one preview). `set_invoice_paid_status(paid=true|false)` replaces `mark_invoice_paid` + `mark_invoice_unpaid` (`paid_at` is refused with `paid=false`). `get_bi(shape="series"|"breakdown")` replaces `get_bi_series` + `get_bi_breakdown` (shape-specific arguments are refused across shapes, never ignored; each shape keeps its own `top_n` default). `edit_checklist_item(action="add"|"toggle")` replaces `add_checklist_item` + `toggle_checklist_item` — one `text` argument whose meaning follows `action`, because the old pair spelled it `text` on one tool and `item_text` on the other with no confirm gate to catch a mix-up. **Removed as strict subsets of the generic pair:** `list_contacts`, `list_projects`, `list_notes`. `list_records(resource="contacts"|"projects"|"notes")` reaches identical data with identical filters; the 2026-08 decision to keep them was priced in tokens, and slots are what bind now. Tool count 96 → 87. Nothing on the REST API changed. **Same day, the follow-through (Wave C):** - **Manifests.** The server is also served as four tag-filtered views — `/mcp/core`, `/mcp/work`, `/mcp/money`, `/mcp/admin` (15 / 24 / 31 / 17 tools) — so a connector mounts `core` plus the planes a firm uses instead of all 87. The combined `/mcp` stays. A hidden tool is not callable through a view. (nginx must route the sub-paths before this reaches a client.) - **`get_capabilities`** answers with a ~1.4K-token index by default (entity → verbs, tool groups, what is off) instead of the 5.6K manifest; `section="entities" | "not_available" | "idempotency" | "curated_tools" | "all"` returns the detail. REST `?surface=mcp` is unchanged. - **Budgets are readable.** `list_records(resource="budgets")` works, and `get_budget_status` takes a client (name or id) and finds the budget itself — refusing with the list when a client has several. Writes stay off. Before this the tool took only an integer id nothing on the surface could produce. - **`log_time` logs for the caller** when no person is given (personal key); an integration key is refused with the arguments that would work. Its docstring used to send the model to `whoami` first for the commonest ask on the surface. - **`get_missed_billing(limit=)`** caps `items` client-side (default 50, `items_total` / `items_truncated`); `summary` is untouched. REST returns the whole detection. - **Renamed:** `create_missing_client_folders_to_cloud` → `create_client_cloud_folders` — 38 chars broke the 64-char client limit under a 32-char connector prefix. - **New guards:** every first line stands alone; the deferred catalogue stays small; every collection read is bounded; no name over 34 chars; the manifests partition the registry. ## 2026-09-01 — Fixed: a product created here is now usable in the Uku product editor A price posted without `active_from` used to be stored as NULL. The billing engine reads NULL as "always active" and priced such a product correctly, and it read back complete here — but the Uku product editor groups prices into PERIODS keyed by that date and requires one to save, so the price belonged to no period. The Products screen could not render it. **What changed on the wire:** omitting `active_from` on `POST /products` (inline prices) or `POST /products/{id}/prices` now stores `2000-01-01` instead of `null`, and that is what the response and every later read return. An `active_from` you send is untouched. The date is deliberately far in the past rather than "today": a price is skipped for any period starting before its `active_from`, so a present-day default would silently stop a back-dated contract from billing its past periods. No migration — nothing in production carried a NULL. ## 2026-09-01 — New: write up to 200 time entries in one request `POST /api/v3/time-entries/bulk` takes `{"entries": [...]}` where each element is exactly the `POST /api/v3/time-entries` body — same required and optional fields, same defaults, same validation, same side effects. Entries may span different tasks, people and days in one call. Why it exists: loading a timesheet, an import, or a month of an agent's work previously cost one HTTP request per entry, and the per-IP rate ceiling (300 r/m) made that the binding constraint long before the app was. 3,288 entries is ~11 minutes of pure rate-limit floor as single writes, and 17 requests batched. **The rate limit is unchanged** — a batch is one request against it whether it carries 1 entry or 200. **All-or-nothing.** If any entry is rejected, nothing is written and `error.details.index` names the offending entry. There is no partial success to reconcile. `data` comes back in request order, and warnings arrive in one flat array with an `index` on each. ⚠ **Send an `Idempotency-Key`.** The path is registered, but `time_entry` has no uniqueness constraint: an unkeyed transport retry of a 200-row batch leaves 400 plausible rows in billable hours and BI. `409 TIMER_ALREADY_RUNNING` and the `TIME_ENTRY_OVERLAP` warning are evaluated against the other entries in the same request as well as stored ones, so a batch behaves exactly like the same entries posted one at a time — including refusing two open (`end: null`) entries for one person. Nothing about the single `POST /time-entries` changed on the wire. It is now implemented as a one-item call into the same writer, so the two cannot drift apart. ## 2026-08-31 — `GET /jobs/{id}` now names the clients it SKIPPED A project-workflow apply reports `skipped_count` for every client that was not visible to your key — no project is created for it, and until now nothing said which of the ids you sent that was. `JobOut` gains **`skipped_client_ids`**, the ids behind `skipped_count`, alongside the `failed_client_ids` that already explained `failed_count`. Additive and non-breaking. It is populated by the **project-workflow apply** job only, where a skip has exactly one meaning (invisible to your key). The client-workflow apply and bulk-member jobs also count skips, but theirs fold together several unrelated reasons (already applied, anchor person not a member, nothing to change) — a mixed list would be undecodable, so they leave it null rather than ship an ambiguous set. ⚠ **`null` means "not recorded", never "nothing was skipped"** — that is also what every job created before today reads. Check `skipped_count` for whether anything was skipped; use this field only to name them. ## 2026-08-31 — Fixed: writes returned 500 INTERNAL_ERROR once a worker had served ~950 requests **UKU-972.** `POST /tasks`, `POST /tasks/{id}/complete` and `POST /time-entries` answered `500 INTERNAL_ERROR` for every company and every key, and `PATCH /tasks/{id}` followed. Not data-dependent, not tenant-dependent, and not fixable by retrying: once a server process crossed the threshold it stayed broken until it was restarted, then broke again. **Cause.** A Sentry SDK / FastAPI incompatibility, not anything in the API's own logic. FastAPI 0.139 rebuilds a route's request handler on every request; the SDK we pinned wrapped the endpoint function each time it was asked for one, and the wrappers stacked. Around 950 requests in, every handler started deep enough that the first database write exhausted Python's recursion limit. Reads survived longest because they need the least stack. **Fix.** sentry-sdk pinned to 2.63.0, which wraps once. No API contract changed. **Nothing to do on your side.** If you have retry logic that treats `INTERNAL_ERROR` as permanent, the affected calls are safe to replay. ## 2026-08-31 — `duration` without `end` now creates a CLOSED time entry **UKU-972 (second finding).** `POST /time-entries` with a `duration` but no `end` used to store the body verbatim, and `end: null` is the only thing that marks a timer as RUNNING. The row was therefore both finished and running: the person's next open-ended entry was refused with `409 TIMER_ALREADY_RUNNING`, and the backoffice showed them a timer they never started. Such a write now closes the entry at `start + duration`. - Omit **both** `end` and `duration` — unchanged, that is still how you open a running timer. - Send `end` — unchanged. - `PATCH /time-entries/{id}` is unchanged: a `duration` there never derives an `end`, because that would stop a timer the caller is only relabelling. ## 2026-08-31 — Missed-billing detection report added (read-only) New `GET /api/v3/missed-billing` — billable work sitting in ALREADY-invoiced (locked) periods that was never billed, detected by the billing engine itself (the same pass the backoffice page runs), so contract matching and pricing are what a re-bill would actually charge. **Gates:** `financials` scope, AND the key's person must hold `bi.analytics` View or be an administrator — checked for every key kind — with the Billing feature active (403 `FORBIDDEN` otherwise). **Params:** `client_id` (optional), `date_from` / `date_to` (ISO dates; default = the current year in the COMPANY timezone, which also drives period bucketing). **Response:** `items[]` (task, period, locked invoice, `net_price`, `estimated_net_sum`, `is_fixed_fee`), `failed_clients[]`, and a `summary` splitting `recoverable_value` from `fixed_fee_value` (reclaiming fixed-fee work recovers WORK, not revenue) with `items_unpriced` for items whose pricing raised. ⚠ **Cost:** one billing-engine pass per contracted client, sequential — tens of seconds for a several-hundred-client firm. Narrow with `client_id` when you can. Read-only on purpose: reclaiming (and undo's creation half) stays backoffice-only — a stated skip, not a gap. ## 2026-08-31 — ⚠ BREAKING: `INVOICE_LOCKED` is gone; invoice ROW writes are allowed **UKU-957.** Yesterday the header PATCH stopped refusing a finalized invoice. Today the row endpoints follow: `POST`, `PATCH` and `DELETE /invoices/{id}/rows[/{row_id}]` no longer answer `409 INVOICE_LOCKED` when the invoice is `created`, `sent`, `paid` or `annulled`. ⚠ **`INVOICE_LOCKED` is no longer returned by any endpoint.** If you branch on it, that branch is dead code. It has been removed from the error catalog. **Why.** The backoffice invoice editor replaces the rows of a finalized invoice wholesale and always has. With only the header relaxed, the two halves of one document disagreed: you could fix the due date of a sent invoice but not a wrong line on it. **What you get instead** — the same `INVOICE_EDITED_WHILE_LOCKED` warning as the header write, so one code covers both, but `details` carry what a row write moves and a header write cannot: ```json {"code": "INVOICE_EDITED_WHILE_LOCKED", "message": "This invoice is paid. A row was changed and the invoice total is now 40.0 — the recipient's copy is out of date. It is recorded as paid 100.0, which is now more than the total.", "details": {"status": "paid", "change": "changed", "total_sum": 40.0, "paid_sum": 100.0, "overpaid": true}} ``` ⚠ **Read that example carefully — removing or shrinking a row on a PAID invoice takes the total below what was already collected**, leaving `paid_sum` greater than `total_sum`. Nothing stops you; the warning names it explicitly with `overpaid: true`. ⚠ **`DELETE` of a row carries NO warning.** It answers `204 No Content`, which has no body to put one in, and we would rather say so than have the same endpoint answer two different status codes depending on the invoice. Read the invoice's `status` before deleting a row if that matters to you. Amounts in `details` are JSON numbers, like every other money field in this API. **Nothing re-runs billing.** An invoice already issued stays as issued, and the already-billed ledger is untouched — these endpoints edit the invoice document, not the billing run behind it. ## 2026-08-30 — A project can now be moved to another client **UKU-959.** `PATCH /projects/{id}` with a different `client_id` answered **`422 VALIDATION_ERROR`** — "A project cannot be moved to a different client." It now performs the move. **Why it was refused, and why that was wrong.** The refusal rested on the backoffice disabling its client picker once a project exists, making the move unsupported everywhere. It does not disable it, and firms use the move. v3 was the only surface refusing a working operation. **What the move does.** The project's tasks move with it, carrying their security objects — the same code path the Uku UI runs. Requires `MANAGE_CLIENT` on the current parent **and** on the destination client. Re-sending the project's current `client_id` stays a no-op. **New: `409 PARTIAL_REPARENT_REFUSED`.** If any of the project's tasks are not visible to the person behind your key, the entire move is refused and nothing is written. A half-moved project would leave those tasks on the old client — visible to its rights holders, invisible to the new one's. `details.stranded_tasks` counts them. ⚠ **What does NOT move with the project.** Client-bound rows hanging off the tasks keep the old client: task automations (so an armed automation keeps emailing the OLD client's contacts), already-invoiced work (`invoice_billed`), and revenue attribution (`invoice_row.original_client_id`). BI splits accordingly — hours and tasks follow the move, revenue does not. Same in the backoffice; this is not new behaviour, but it is newly reachable from the API. ## 2026-08-30 — ⚠ Invoice-row tax is server-derived, and `tax_rate` is a fraction **UKU-957.** Writing an invoice row no longer takes your `tax_rate`, `tax_name` or `tax_sum`. `tax_id` alone decides the tax: the server reads the rate and name off your company's own tax record and computes `tax_sum` from the effective `net_sum` — the same `InvoiceModule.calculate_tax` the Uku UI has always used. No `tax_id` means no tax. **Read this even if you already send `tax_id`.** `tax_rate` is a **fraction**: `0.22` is 22% VAT. That is what the column holds everywhere in Uku — the invoice PDF, the BI reports, the Xero/Merit/e-conomic/QuickBooks exports. Until now this endpoint read your value as a *percentage* and divided by 100, so: - if you echoed back the `0.22` a `GET` gave you, the row was charged **0.22% tax** — a hundredth of what was owed; - if you sent `22`, you wrote a **2200% rate** into a column every other reader treats as a fraction. No production row was ever written above `1`, so we believe nobody was caught by this; if you have reconciled invoices created through `POST /invoices/{id}/rows` since 2026-07-10, it is still worth checking their `tax_sum`. The field is now documented as ignored on write, and its upper bound was **removed rather than narrowed to `le=1`**: sending `22` still succeeds and now gets the correct tax from `tax_id`, where a tighter bound would have 422'd the integration that followed our own published example. `net_sum` was already derived this way (quantity × net_price − discount) and is unchanged. ## 2026-08-30 — Editing a finalized invoice is allowed, and warns **UKU-957.** `PATCH /invoices/{id}` answered **`409 INVOICE_LOCKED`** whenever the invoice was `created`, `sent`, `paid` or `annulled` — every status an invoice reaches after it stops being a draft. It now succeeds. **Why the refusal was wrong.** The backoffice invoice editor has always let an accountant fix a sent invoice's due date, comments or reference number, and firms use that. v3 refused the same edit, which made the API the strictest door in the product for a field set that carries no money at all: `InvoiceUpdate` reaches only `status`, `invoice_date`, `due_date`, `reference_number`, `comments` and `locale_code` — never sums, rows, client or `invoice_number`. **What you get instead.** A `200` whose body carries a non-blocking warning: ```json {"data": {"id": 42, "status": "sent", "...": "..."}, "warnings": [{"code": "INVOICE_EDITED_WHILE_LOCKED", "message": "This invoice is sent. It was edited and its status was kept — the recipient's copy is now out of date.", "details": {"status": "sent", "edited_fields": ["comments"]}}]} ``` The status is **preserved** — a `sent` invoice stays `sent`. Same channel as `TIME_ENTRY_OVERLAP` / `MONITOR_OVERLAP`. ⚠ **If you branch on `INVOICE_LOCKED` from this endpoint, that branch is now dead.** It is still returned by the ROW endpoints (`POST/PATCH/DELETE /invoices/{id}/rows[/{row_id}]`) — those move money and stay refused on a finalized invoice. > **Superseded 2026-08-31** — the row endpoints stopped returning it too. `INVOICE_LOCKED` > is now returned by no endpoint and is out of the error catalog. See the 2026-08-31 entry. `If-Match` is unchanged and still required, so an unversioned edit of a finalized invoice now answers `428` where it used to answer `409`. ## 2026-08-30 — ⚠ Note writes are sanitized; `document` notes now need `documents.manage` **UKU-958.** `POST /notes` and `PATCH /notes/{id}` built the row directly and skipped three things the backoffice has always done. All three now apply on v3 and MCP alike. **1. `comments` is sanitized on write.** It was stored verbatim, so a `