# Uku API v3 — Changelog ## 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 `