# Timjami API > Multi-tenant time tracking. 417 operations under `https://timjami.app/api/v1`, API version 0.1.0. Generated from this server's own OpenAPI document, so it describes the deployment that served it rather than a build somebody remembered to run. The HTML reference is at https://timjami.app/docs. The domain API OpenAPI document is at https://timjami.app/api/v1/openapi.json; Better Auth's separate Auth & organizations document is at https://timjami.app/api/auth/open-api/generate-schema. A shorter index is at https://timjami.app/docs/llms.txt. ## Getting started Timjami tracks time for more than one organization at once, and everything below happens inside exactly one of them: the one your credential carries. There is no organization parameter anywhere in this API, and that is a deliberate rule rather than an omission — a request that could name its own tenant is a request that can name somebody else's. ### In one minute Mint a personal access token in the web app, at **Verbindungen → API-Keys**, tick the scopes you need, and call the API with it: ```bash curl -s https://timjami.app/api/v1/projects \ -H 'Authorization: Bearer thyp_…' ``` ```jsonc { "data": [ { "id": "7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33", "name": "Website-Relaunch", "customerId": "…", "rules": [{ "taskId": null, "personId": null, "validFromMonth": "2026-01", "hourlyRateMinor": 9900 }] } ] } ``` That token acts **as you**, in **one organization**, and may do exactly what you ticked. It is shown once; only a hash is stored. A lost token is replaced, never recovered. ### Then Recording an hour takes three ids, and they nest: a **customer** has **projects**, a project has **tasks**, and a time entry hangs off a task. So the shortest useful path through the API is `GET /tasks` → `POST /time-entries/start` → `POST /time-entries/{id}/stop`. If you are recording time that already happened, skip the timer and `POST /time-entries` with `startedAt` and `endedAt` — it is the same row either way, and only one entry per person may be running at a time. ### The one thing that will surprise you A time entry stores **no money at all**. `isBillable`, `billableRateMinor`, `currency` and `rateAxes` are on every entry you read, and every one of them is *computed* for that response: billability from the task, the rate from the project's rules for the month the work falls in, the currency from the organization. `rateAxes` says which axes the rule that decided constrains — `[]` is the project's default rule, `null` means nothing priced these hours. So repricing a project **does** change what already-recorded hours are worth. That is the point rather than a hazard — a rate is dated, so "from September we charge more" is entered in July and prices September, and correcting July's rules corrects July. Treat any amount you read as provisional. What cannot change is a **document**. Creating an invoice freezes what it bills: its lines keep their own quantity, unit rate and amount, and each claim records which seconds of which entry it billed and at what rate. Nothing recomputes either, ever. If you need the number somebody was actually charged, read the invoice — not the entries behind it. Two consequences worth knowing before you write against this: - An entry may be **partly** billed. Extend one after it was invoiced and the new time is billable on its own; `GET /invoices/unbilled` counts seconds, not entries. - `PATCH /time-entries/{id}` answers **409** if you move the times or the task of an entry an invoice bills. The note stays editable. A token never gets an exception to this, whatever role its owner holds. ## Authentication Timjami publishes two API contracts from the server you are reading: - [Domain API OpenAPI document](https://timjami.app/api/v1/openapi.json) — time tracking, projects, customers, budgets, invoices, reports and integrations under `/api/v1`. - [Auth & organizations OpenAPI document](https://timjami.app/api/auth/open-api/generate-schema) — Better Auth's sign-in, account, session, invitation and organization-membership endpoints under `/api/auth`. The second document is generated by the installed Better Auth version, including its enabled plugins. It is the canonical reference for those endpoints; the paths inside it are relative to its `/api/auth` server URL. For example, its `GET /organization/list-members` operation is called as `GET /api/auth/organization/list-members`. It lists the members of one organization, not every user on the deployment, and requires the caller to have a signed-in session and to belong to that organization. Three credentials reach the domain API, and each operation below says which of them it accepts. | You are | Use | Get one | |---|---|---| | a script, a cron job, a server you control | a **personal access token** | mint it yourself — no OAuth round trip | | an application other people connect to *their* Timjami | **OAuth 2.1**, authorization code + PKCE | each person approves it and picks an organization | | Timjami's own web, desktop or CLI client | a **session** | not for third parties | Both scoped credentials are **user-owned**. They act as the person who created or approved them, and they stop working the moment that person leaves the organization. There is no organization-owned service account. ### A personal access token `POST /api/v1/access-tokens` with a name and a list of scopes, or the same thing in the web app. Four things to know: 1. **It is shown once.** Only a hash is stored. 2. **It carries the organization that was active when you minted it.** Not a parameter, not switchable — mint a second token for a second organization. 3. **Send it as `Authorization: Bearer thyp_…` or `X-API-Key: thyp_…`.** Both work. The `thyp_` prefix is what makes it recognisable in a log or to a secret scanner. 4. **1000 requests an hour**, per token. Minting one needs a session. A token cannot mint another — the second would survive the first's revocation and there would be no chain to follow. ### OAuth 2.1 Timjami is the authorization server. Discovery is at the root, where RFC 8414 and RFC 9728 put it: ``` GET /.well-known/oauth-authorization-server GET /.well-known/oauth-protected-resource/api/v1 GET /.well-known/oauth-protected-resource/api/v1/mcp ``` PKCE is required. The authorization request always goes through Timjami's approval page, where the person chooses **which organization** the client may act in: an access token carries no organization of its own, so a client that skipped that page would hold a credential that resolves to nothing. ### Why the two are not equal An OAuth client here registered *itself*, unauthenticated, under a name it chose — that is what dynamic client registration is. So a token issued to one can hold only six scopes: `thymian:read`, `thymian:track`, `thymian:customers.write`, `thymian:budgets.write`, `thymian:hints.write` and `thymian:hints.read`. A personal access token can hold the whole ordinary catalogue, because you minted it yourself, in your own settings, from a list that said what each one does. If your integration needs invoices, project or task structure, organization settings, reports or budget maintenance, it needs a personal access token. An authorization request naming a scope outside the six is refused with `invalid_scope` before anybody is shown an approval page. `thymian:budgets.write` is the deliberately narrow exception among those OAuth scopes. Create, supersede and monthly adjustment set/remove also require a live, per-client switch under *Verbindungen → KI-Assistenten*, current organization membership and the current role's `budget.write` capability. The switch does not add a scope to an existing token. Correction, archive/restore and deletion are unavailable to OAuth even with the switch; sessions and personal tokens keep them. `thymian:customers.write` is the second narrow OAuth scope, and unusual in a different way: the four routes it names — create a customer, edit one, set or remove its logo — still also accept `thymian:projects.write`, the scope that governed them before this one existed. So a personal access token minted with `thymian:projects.write` still reaches them without being re-minted, and an OAuth client only needs `thymian:customers.write` when that is the whole of what it should be able to touch. Archiving a customer is not part of this scope and stays on `thymian:projects.write` alone. One route group is an exception worth knowing: `/api/v1/timer/*` names `thymian:timer`, which is reserved for the automatically provisioned Apple Watch key and cannot be requested by any OAuth client — but it also accepts `thymian:read` **and** `thymian:track` held together, since that pair already grants strictly more than `thymian:timer` does. An OAuth client holding both reaches these routes without ever holding the Watch-only scope. ## Scopes One catalogue, whatever the credential. Every operation in the reference below names the scope it needs, and the table is read from the same constant the server enforces against — so a scope documented here is a scope something actually checks. **A scope never widens a role.** A credential's authority is its scopes *intersected* with what its owner may already do. `thymian:reports.read` on a plain member's token still answers `403`, because the report is owner and admin only. Scopes narrow; roles decide. **A scope is not a way to reach everything.** Some operations accept a session and nothing else, whatever scopes a token holds — minting a credential, approving an OAuth client, connecting Slack, Google Calendar or Harvest, importing from Harvest, exporting time entries to it, and changing your own preferences. Each of those hands out access or changes what you have agreed to, and that stays something a person does while signed in. They are marked **session only** below. **`thymian:hints.read` needs a second yes — but only from an OAuth client.** A hint stream is prompts, branch names and message text, and the scope was agreed to in the middle of connecting somebody else's application. So a connected client also needs *Verbindungen → KI-Assistenten → Assistenten dürfen meine Hints lesen* switched on, read fresh on every request. A personal access token is not in that situation, so the switch does not apply to it. Either way, hints are only ever your own. **`thymian:budgets.write` needs a live per-client grant over OAuth.** Scope, current membership, the current role's `budget.write` capability and the switch under *Verbindungen → KI-Assistenten* are checked on every ordinary budget write. Turning the switch on never adds a missing scope to the token. It permits create, supersede and monthly adjustment set/remove; correction, archive/restore and delete remain unavailable to OAuth and therefore are documented without the OAuth security scheme. Sessions and personal access tokens keep their existing access. | Scope | Grants | Operations | Available over OAuth | |---|---|---|---| | `thymian:read` | Read the organization’s customers, projects, tasks, budgets and recorded time — everybody’s hours, not only yours — and its settings. | 48 | yes | | `thymian:track` | Start and stop your timer and manage your own breaks. Record, correct and delete time entries in the organization, a colleague’s included, as every member may. Record absences for yourself — or for anybody, if you administer the organization. | 20 | yes | | `thymian:timer` | Read and operate only your current timer, your completed time today and your five most recently used tasks. | 11 | no | | `thymian:master-data.read` | Read the organization’s customers, projects and tasks. No time, budget, rate or member. | 0 | yes | | `thymian:master-data.write` | Create and edit customers, projects and tasks — names, codes, colours, logos, a project’s visibility, a task’s category, and a new task’s billability. Cannot archive, set rates, change members, or change an existing task’s billability. | 0 | yes | | `thymian:project-members.read` | Read who is assigned to a project. | 0 | yes | | `thymian:project-members.write` | Assign people to projects and remove them, as far as you may. | 0 | yes | | `thymian:project-rates.read` | Read a project’s rate rules. | 0 | yes | | `thymian:project-rates.write` | Set a project’s rate rules and whether a task is billable. Never for hours an invoice already bills. | 0 | yes | | `thymian:time.read-own` | Read your own time entries, your running timer and your totals. Nobody else’s. | 0 | yes | | `thymian:time.write-own` | Start and stop your timer, record time and correct your own entries. Nobody else’s. | 0 | yes | | `thymian:time.read-others` | Read colleagues’ time entries as the organization’s time list shows them to you; a named person only if you administer the organization. | 0 | yes | | `thymian:budgets.read` | Read budgets and how many hours they have used. No money. | 0 | yes | | `thymian:invoice-drafts.write` | Build an invoice draft from tracked time and agreed work, and delete a draft. The answer shows the draft’s amounts even with money reading off, so building and deleting a draft reads them. Nothing is sent, exported or transferred: a person does that. Only works once billing writes are switched on for this client. | 0 | yes | | `thymian:time-allowances.read` | Read monthly allowances („Pauschalen“) and their monthly adjustments, in hours. No money. | 0 | yes | | `thymian:time-allowances.write` | Create, replace, correct and delete monthly allowances („Pauschalen“) and their monthly adjustments, which decide the hours a customer is billed. Never a month a live invoice holds. Only works once billing writes are switched on for this client. | 0 | yes | | `thymian:fixed-price.read` | Read fixed-price agreements and items, including their amounts. Only works once money reading is switched on for this client. | 0 | yes | | `thymian:fixed-price.write` | Create, change and delete fixed-price agreements and items, and link a task to an agreement, which decide what a customer is billed. The answer shows the amounts the call wrote. Never an item an invoice already bills. Only works once billing writes are switched on for this client. | 0 | yes | | `thymian:projects.write` | Create and change customers, projects and tasks, including archiving them. | 15 | yes | | `thymian:customers.write` | Create and edit customers — name, aliases, code, colour and logo. Cannot archive a customer, and cannot touch projects or tasks. | 5 | yes | | `thymian:project-icons.write` | Set or remove a project's logo. Cannot rename a project, touch its rate rules or members, or archive it. | 2 | yes | | `thymian:budgets.write` | Agree and supersede budgets, and set or remove monthly adjustments. Connected clients cannot correct, archive, restore or delete budgets. | 13 | yes | | `thymian:invoices.read` | Read invoices, their lines and amounts, write-offs, and what has not been billed yet. For a connected client this stays off until you also switch it on for that client in Timjami’s settings. | 14 | yes | | `thymian:invoices.write` | Build, issue, cancel and export invoices. | 16 | no | | `thymian:billing.read` | Read recurring billing rules and proposals — customer, period, recurrence, due date, state and affected projects. No amount, rate, or invoice. | 4 | yes | | `thymian:billing-proposals.write` | Confirm a billing proposal with nothing on it needs no invoice, or move a service between two open proposals of the same rule. Cannot create, pause or resume a rule, or touch an invoice. | 2 | yes | | `thymian:billing-rules.write` | Create a recurring billing rule, or change its schedule or which projects it covers from a future period. Cannot pause or resume a rule, move a proposal’s service, or touch an invoice. | 3 | yes | | `thymian:invoice-status.read` | Read an invoice’s customer, period, status and transfer state — draft, transferred to the invoicing tool, or withdrawn. No amount, rate, currency or the invoicing tool’s document. | 1 | yes | | `thymian:organization.write` | Change the organization’s settings — currency, time zone, working hours — and set somebody’s working time model. | 8 | no | | `thymian:reports.read` | Read organization-wide aggregates over everybody’s hours, revenue included. Only works if you are an owner or admin; for a third-party client the revenue stays off until you also switch money reading on in Timjami’s settings. | 11 | yes | | `thymian:hints.write` | Report what it observed you working on. | 6 | yes | | `thymian:hints.read` | Read back what collectors observed about you — prompts, branches, message text. For a third-party client this stays off until you also switch it on in Timjami’s settings. | 5 | yes | | `thymian:absence-subjects.read` | Read the people absences can be recorded against — name, email and whether they are still active. No customers, projects, tasks, time or money. | 1 | no | | `thymian:persons.read` | Read the organization’s people — id, name, email and whether they are still active. No customers, projects, tasks, time or money. | 1 | yes | | `thymian:planning.read` | Read the organization’s staff plan, customer and project requirements, person experience, budget planning context, allocation notes, person names, weekly capacity, absence days (kinds only with absence details), tracked weekly totals and budget hours. Only works if you are an owner, admin, or hold the planning role. | 3 | yes | | `thymian:planning.write` | Create, replace or clear any person’s planned hours and note on a customer, budget or project for an ISO week, and planning context for customers, persons, projects and budgets. Only works if you are an owner, admin, or hold the planning role. Does not record time or change budgets. | 4 | yes | Operations that accept a session and no scoped credential at all: 203. Operations that need no domain scope beyond authenticating: 3. ## Conventions Six rules hold across every operation below. They are worth reading once, because each of them is a place where an integration written against a reasonable assumption goes quietly wrong. **Money is an integer in minor units.** `billableRateMinor: 8500` with `currency: "EUR"` is 85,00 €. Never a float, anywhere, in either direction. Rounding happens once, at the edge — and *which* edge differs on purpose: an invoice line rounds its own whole seconds once, while a day's on-screen total sums per-entry amounts. The two disagree by cents and both are correct for what they are. **Rates on the wire are always hourly.** A day rate is a display, computed from the organization's `hoursPerDay`. There is no daily rate stored anywhere and none accepted here. **Timestamps are ISO-8601 with an offset** — `2026-07-29T09:00:00.000Z` or `2026-07-29T11:00:00+02:00`, both fine. Calendar days are different: an absence's `fromDay` and a budget's `validFromMonth` are `YYYY-MM-DD` and `YYYY-MM` strings, because a calendar day has no offset and storing one as a timestamp moves it across a timezone boundary. **Lists are `{ "data": [...] }`**, and a paginated one adds `nextCursor`. Pass that value back unchanged as `cursor`. It names both the instant and the row, so entries sharing a `startedAt` are not skipped at a page boundary — which is not a rare case, because every entry backdated to "09:00" shares an instant to the microsecond. `nextCursor: null` means you have the whole set; a non-null one means you do **not**, and summing what you have is a wrong number that looks like a right one. **The organization is never a parameter.** It comes from the credential. Read `GET /me` to find out which one you are in. **Nothing is deleted quietly.** Archiving is the normal end of a customer, a project or a task; `includeArchived=true` brings them back into a list. Deleting is refused with `409` when something downstream depends on the row — an invoiced time entry, a budget with hours measured against it. ## Analytics export Timjami does not push anywhere and runs no scheduler. If you want valued hours in Elasticsearch — or in any other analytics store — you run a cron next to that store, pull from two endpoints, and write what they hand you. Timjami holds no credentials into your index, and the token you point at it is read-only. ### The unit One document is **one valued stretch of one finished time entry**: person, task, project, customer, budget, rate, amount, currency, and a `status`. Not one per entry — an entry whose first hours are billed by a transferred invoice while the rest is still open is *two* documents, and one straddling a budget's capacity boundary is two as well. Non-billable time is exported too, with no rate and no amount: the billable rate (the share of tracked time that was billable) needs it as its denominator. The id is `::` — the month, the entry, and the stretch's start offset in seconds within that entry. It guarantees the start, the month and uniqueness; it does **not** guarantee the end. A moved allocation boundary is a tombstone plus a new document rather than an update, because the old id described seconds nobody priced that way. ### Two endpoints ```bash # Everything in one month, complete. This is a first sync, a rebuilt index, # and the nightly full run — the same code path each time. curl -s 'https://timjami.app/api/v1/reports/projection?month=2026-07' \ -H 'Authorization: Bearer thyp_…' # Everything that changed since your last pull, tombstones included. curl -s 'https://timjami.app/api/v1/reports/projection/changes?since=2026-08-13T02:00:00.000Z' \ -H 'Authorization: Bearer thyp_…' ``` Both need a token with `thymian:reports.read`, held by somebody with the `owner` or `admin` role. That is not bureaucracy: those roles see private projects, and a token belonging to anybody else would produce an index quietly missing one of them. Both **recompute and store on the way through**. Every month is recomputed on every run, diffed against what was last delivered, and the new state persisted — a value here moves without its time entry being touched (a dated rate lands, a budget is corrected, a colleague's backdated hour shifts the allocation), so there is no "this month cannot have changed" shortcut, and asking is what makes the answer current. ### The loop ```jsonc // GET /api/v1/reports/projection/changes?since=… { "data": [ { "id": "2026-07:0f4d…:0", "month": "2026-07", "changedAt": "2026-08-14T02:00:03.412Z", "deleted": false, "document": { "id": "2026-07:0f4d…:0", "seconds": 3600, "amountMinor": 9000, "status": "final", /* … */ } }, { "id": "2026-06:1a7c…:0", "month": "2026-06", "changedAt": "…", "deleted": true, "document": null } ], "nextCursor": null, "nextSince": "2026-08-14T02:00:03.412Z", "fullResyncRecommended": false } ``` 1. Start from the full export, month by month, and keep its `syncedAt`. 2. From then on call `/changes` with `since` set to the stored stamp. 3. Page while `nextCursor` is not null, passing it back as `cursor` with the **same** `since`. When it is null the delta is complete. 4. Write your batch. **Then** store `nextSince` as your new cursor. Step 4 in that order is the whole reliability story. The cursor is inclusive and answers from stored documents rather than from the run's diff, so a job that dies mid-write asks again with the same `since` and is told the same thing. Every id is an upsert, so the repeat costs nothing. Advancing the cursor first is how a crash loses a document silently. ### `deleted` is not optional **A document with `deleted: true` means delete that id from your index.** It carries no body. Skip this branch and every hour ever deleted or re-dated in Timjami stays in your dashboards forever, looking exactly like an hour somebody worked. A re-dated entry arrives as precisely this pair: a tombstone in the old month, a document in the new one. Tombstones are kept for 30 days, which is therefore exactly how far back a `since` may reach. Ask with an older one and the answer carries `fullResyncRecommended: true` — call the full export instead of trusting that delta, because what disappeared while you were gone can no longer be named. ### More than one destination Nothing here is registered, so any number of jobs may pull. The cursor lives on your side: `/changes?since=T` is a function of `T` and the stored state — every stored document at or after `T`, not the diff of the run that happens to answer — so one job pulling does not consume anything another job has not fetched yet. Two destinations each keep their own `since` and neither notices the other. Three things are shared, and none of them couples the consumers. Each pull recomputes, so five jobs mean five recomputes of the same months (harmless — the valuation is deterministic, concurrent pulls converge — but it is work). The 30-day tombstone horizon is absolute rather than measured from anybody's last pull, so a fast consumer never shortens a slow one's window. And the state is a snapshot rather than a history: you always receive a document's *current* value, never the intermediate states you were offline for. That is the right shape for upserting into an index and the wrong shape for keeping a change log. ### A nightly full run Keep one, even though every pull already reconciles everything. It insures against the *consumer* side — a rebuilt or re-mapped index, a bug in your own write path — and it is the same request as a first sync, so it costs nothing to keep: ```bash #!/usr/bin/env bash set -euo pipefail month="$(date -u +%Y-%m)" cursor="" while :; do page=$(curl -sf -H "Authorization: Bearer $THYMIAN_TOKEN" \ "$THYMIAN/api/v1/reports/projection?month=$month&limit=1000${cursor:+&cursor=$cursor}") # One `{"index": …}` action line per document, then the document itself. jq -c '.data[] | {index: {_index: "thymian-hours", _id: .id}}, .' <<<"$page" \ | curl -sf -H 'Content-Type: application/x-ndjson' --data-binary @- \ "$ES/_bulk" > /dev/null cursor=$(jq -r '.nextCursor // empty' <<<"$page") [ -n "$cursor" ] || break done ``` The delta's write is the same, with the tombstones split off: ```bash jq -c '.data[] | select(.deleted | not) | {index: {_index: "thymian-hours", _id: .id}}, .document' <<<"$page" jq -c '.data[] | select(.deleted) | {delete: {_index: "thymian-hours", _id: .id}}' <<<"$page" ``` ### Reading the numbers `status` is the field every dashboard has to carry. `final` is a stretch a **transferred** invoice claims — money that has left Timjami for the invoicing tool and cannot change here any more. Everything else is `provisional`, including a draft invoice's hours, and it is provisional in the literal sense: it will move if somebody reprices the project or a colleague backdates an hour into the same budget. It is also **not monotonic**. Withdrawing an invoice releases its claims, and those seconds go back to `provisional` at a recomputed value. A dashboard that sums `final` and `provisional` money into one unlabelled number is stating something nobody can defend; facet on the field instead. Two more, both small and both the kind of thing that is found late: - `rateMinor: null` means nothing priced those seconds. It is never `0`. Billable hours with no rate are a real state and worth a panel of their own. - A document rounds over its own seconds, while an invoice line rounds once over its whole quantity. Summing `final` money therefore *approximates* what was invoiced, to within a minor unit per document. If you need the invoiced total, read the invoice. Finally, the projection is disposable at both ends. Timjami never reads its own sync state back for anything but this export, and your index can be dropped and rebuilt from the full export at any time. ## Errors Every error is an RFC 9457 problem document, served as `application/problem+json`: ```jsonc { "type": "https://timjami.suora.cloud/problems/forbidden", "title": "Forbidden", "status": 403, "detail": "This credential does not carry thymian:invoices.write.", "requiredScope": "thymian:invoices.write", "requestId": "7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33" } ``` `type` is stable and is the thing to branch on. `detail` is written for a human and may be reworded; `title` and `status` are what you would guess. Every response also carries the same value in an `X-Request-Id` header — quote it when reporting a problem, it is what correlates a support request with the API's own logs. `requestId` in the body is absent on the rare error a request never reaches this API's own error handler for (better-auth's `/api/auth/*` routes, most notably); the header is set regardless. ### What the codes mean here | Code | Means | Do | |---|---|---| | `400` `no-active-organization` | a session with no organization chosen — only reachable with a session | call Better Auth's `POST /api/auth/organization/set-active`; see the Auth & organizations OpenAPI document linked in the Authentication guide | | `400` `validation-error` | the body is not valid JSON | fix the JSON; `title` says it was malformed | | `401` | no credential, or one that is invalid, expired or revoked | authenticate; the `WWW-Authenticate` header names the metadata document | | `403` + `insufficient_scope` | the credential is fine, the scope is not | `requiredScope` in the body names it | | `403`, no challenge | a session-only operation, a role you do not hold, or no approved organization | `detail` says which | | `404` | not found, **or** found in another organization | the two are deliberately indistinguishable | | `409` | a real conflict: a second running timer, an invoiced entry you tried to delete, a budget with hours against it | read `detail`; retrying unchanged will not help | | `415` `validation-error` | a body sent without `Content-Type: application/json`, or a `Content-Type` this route does not read — even with no body | send JSON as `application/json`; where the body is optional, send no body and no `Content-Type` | | `422` | the request did not match the schema | `errors` lists the fields | | `429` | 1000 requests an hour, per token | back off | **A 401 is not a scope problem and a 403 is not an authentication problem.** A client that retries a 403 with the same credential gets the same answer forever. This is the single most common integration bug against this API. ### Two that are not errors `GET /time-entries/running` answers `404` when no timer is running. That is the normal answer to "is anything running", not a failure. `POST /time-entries/start` answers `409` when a timer is already running — including one started on another device, which is exactly why it is a conflict and not an overwrite. Send `stopRunning: true` to stop the old one and start the new one in a single request, but only after deciding that stopping it is what the person meant. ## Undocumented, as of this request 1847 of 4364 things in this document have no prose on them, counted on this request. - **Operations with a summary and nothing else** — 56 of 417. The summary says what it is called. Without a description there is nowhere to say what it does to the data, or when not to call it. - **Groups with no description** — 30 of 45. A group heading with no sentence under it makes the reader open every operation in it to find out what the group is for. - **Parameters with no description** — 70 of 434. A type tells you a parameter takes a string. It does not tell you what happens when you omit it. - **Response and request fields with no description** — 1691 of 3468. The largest of the four and the least urgent: most are named well enough to guess. The ones that carry a rule — a frozen rate, a cursor, a month — are the ones worth writing. ## Me Current user, preferences, effective display settings. ### GET /api/v1/me Current user, preferences and effective display settings One call that gives a client everything it needs to render rates consistently. `effectiveRateDisplay` is resolved server-side (user override, else organization default) so that web and native clients cannot disagree about which mode is in effect. Unlike the other routes here, this one does not require an active organization — a freshly registered user has none yet and still needs to load the app. - Scope: none beyond authenticating - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Me` | application/json | The current user and settings. | **Problems** (RFC 9457 `application/problem+json`): 401. ### GET /api/v1/me/assistant-tours Where you stand in Jami’s guided tours One entry per tour this account has been offered or has started. An `onboarding` with no entry has never been offered — the web app starts it on arrival, while the tour is enabled for the caller (`effectiveTourEnabled` on `GET /me`). A `menu-tabs` with no entry is never shown. Per account, not per organization (ADR-2026-09-28-12X97). - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AssistantTours` | application/json | The tours this account has progress in. | **Problems** (RFC 9457 `application/problem+json`): 401, 403. ### PUT /api/v1/me/assistant-tours/{tour} Record where you stand in one of Jami’s tours `active` with a `step` starts, resumes or restarts the tour there; `completed` and `dismissed` end it. Idempotent, hence PUT. `offered` is not writable — only the server offers a tour, when it becomes available to an organization that already has members. Refused in a support session: what the account has been shown is the account holder’s, not the support person’s. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `tour` | `"onboarding" \| "menu-tabs"` | yes | Which of Jami’s tours this is: `onboarding`, the tour itself, or `menu-tabs`, his note that the main menu now groups pages as tabs. | **Request body (application/json)**: PutAssistantTourProgress **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AssistantTourProgress` | application/json | The recorded progress. | **Problems** (RFC 9457 `application/problem+json`): 401, 403, 422. ### PUT /api/v1/me/avatar Set your own avatar Replaces the avatar, or sets the first one. Idempotent, hence PUT. The image must arrive ready to store: a **square** PNG or WebP within the documented size limits — identical rule to a customer or project icon. The server neither crops nor resizes; the client does that (docs/decisions/0020-icons-are-normalised-in-the-browser.md). - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `contentType` | `"image/webp" \| "image/png"` | yes | Must match what the bytes actually are; a mismatch is rejected. | | `data` | `string` | yes | Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ResourceIcon` | application/json | The new avatar. | **Problems** (RFC 9457 `application/problem+json`): 401, 422. ### DELETE /api/v1/me/avatar Remove your own avatar Idempotent: having no avatar already still answers 204. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | No avatar is set. | **Problems** (RFC 9457 `application/problem+json`): 401. ### POST /api/v1/me/google-provision Join the organization claimed by the signed-in Google Workspace domain Idempotent and session-only. It trusts the verified Google ID token hosted-domain claim, never an email suffix. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `GoogleProvisionResult` | application/json | The provisioning outcome. | **Problems** (RFC 9457 `application/problem+json`): 401. ### DELETE /api/v1/me/impersonation-session End the current support session Revokes only the impersonation session presented as a bearer token. The operator session cookie and any other support session are never changed. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The current support session was revoked. | **Problems** (RFC 9457 `application/problem+json`): 401, 403. ### POST /api/v1/me/invite-code Redeem an invite code for a signed-in account without an organization Turns an admitted-but-unspent invitation into the same standing a code gives at sign-up: this account may then create one organization (invariant 52). Refused once the account already belongs to an organization — a redemption would spend a use for nothing — or has already redeemed a code. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `code` | `string` | yes | An invite code, in any casing or spacing — normalized server-side. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The code was redeemed. | **Problems** (RFC 9457 `application/problem+json`): 401, 409, 422. ### PATCH /api/v1/me/preferences Update personal preferences Setting `rateDisplay` to `null` means "follow the organization". This changes presentation only — no stored rate is touched (ADR 0012). `dayTab` is stored only when it differs from the remembered one, and a support session’s `dayTab` is ignored (ADR-2026-11-02-15KRK). - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `rateDisplay` | `"hourly" \| "daily" \| null \| null` | no | How rates are presented in the UI. Does not affect stored data. | | `locale` | `string` | no | | | `timeZone` | `string \| null` | no | | | `llmHintEvaluationConsented` | `boolean` | no | | | `keyboardShortcutsEnabled` | `boolean` | no | | | `runningTimerAvatarsEnabled` | `boolean` | no | | | `tourEnabled` | `boolean` | no | | | `jamiEnabled` | `boolean` | no | | | `forgottenTimerEmailEnabled` | `boolean` | no | | | `forgottenTimerThresholdSeconds` | `integer` | no | | | `operatorNewUserEmailEnabled` | `boolean` | no | | | `operatorNewOrganizationEmailEnabled` | `boolean` | no | | | `organizationNewMemberEmailEnabled` | `boolean` | no | | | `dailyOrgReportEmailEnabled` | `boolean` | no | | | `dailyOrgReportSendWeekdays` | `integer[]` | no | | | `dailyOrgReportSendHour` | `integer` | no | | | `budgetAlertEmailEnabled` | `boolean` | no | | | `dayTab` | `"list" \| "calendar" \| "max"` | no | „Heute“’s tabs: `list` („Liste“), `calendar` („Kalender“) and `max` („Max 2.0“). | | `hintMaxViewEnabled` | `boolean` | no | | | `hintCorrectionsEnabled` | `boolean` | no | | | `hintBoundariesEnabled` | `boolean` | no | | | `hintMinDeviationSeconds` | `integer` | no | | | `hintMinDurationSeconds` | `integer` | no | | | `hintConfirmationsEnabled` | `boolean` | no | | | `hintInboxEnabled` | `boolean` | no | | | `hintEvidenceLabelsEnabled` | `boolean` | no | | | `hintExcludedSources` | `string[]` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `UserPreferences` | application/json | The updated preferences. | **Problems** (RFC 9457 `application/problem+json`): 401, 403, 422. ### GET /api/v1/me/sessions List every active session belonging to the current user Includes browser, native, CLI, and operator support sessions. An impersonated support session cannot inspect the target account security inventory. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `UserSessions` | application/json | Every active session owned by the caller. | **Problems** (RFC 9457 `application/problem+json`): 401, 403. ### DELETE /api/v1/me/sessions/{sessionId} Revoke one session belonging to the current user Uses the opaque inventory identifier. The reusable bearer credential is never exposed to another session. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `sessionId` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The session was revoked. | **Problems** (RFC 9457 `application/problem+json`): 401, 403, 404. ### POST /api/v1/me/watch-credential Replace the current user’s Apple Watch credential - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `WatchCredential` | application/json | The credential, shown once. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/me/watch-credential Revoke Apple Watch credentials - Scope: none beyond authenticating - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The credential or credentials were revoked. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Organization Settings of the active organization. ### GET /api/v1/organization-icons/{token} Serve an organization's icon Returns the image bytes for a signed token, as found in `organization.icon.url`. Deliberately not authenticated with a session or a bearer token, for the same reason a customer or project icon is not — see `GET /icons/{token}`. Do not build these URLs; read them from `GET /organization/icon`. Do not store them either — a stale one answers 404, which is the signal to refetch. An image stored as WebP is sent as WebP only when `Accept` names `image/webp` explicitly; a wildcard such as `*/*` gets it re-encoded as PNG, because some clients (watchOS) send only wildcards and cannot decode WebP. A stored WebP that cannot be re-encoded is sent as WebP regardless of `Accept`. An image stored as PNG is always sent as PNG. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `token` | `string` | yes | Opaque signed token from `organization.icon.url`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | image/webp, image/png | The image. | | 304 | — | — | Not modified — the client already has these bytes. | **Problems** (RFC 9457 `application/problem+json`): 404. ### PUT /api/v1/organization/billing-escalation Set the global billing-escalation recipient and delay Requires the `owner` or `admin` role and a signed-in session — never a token. `delayDays` counts calendar days after a proposal’s due date before `recipientPersonId` joins the rule’s own responsible people, and before weekly repeats begin. `recipientPersonId` must be a live member; `null` leaves the obligation visibly unset in Settings rather than silently pointing at nobody. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `delayDays` | `integer` | yes | Calendar days after a proposal’s due date before the global recipient joins the rule’s own responsible people, and before weekly repeats begin. | | `recipientPersonId` | `string (uuid) \| null` | yes | The second-level recipient every escalated or weekly-repeated proposal email also reaches. `null` leaves the obligation visibly unset rather than silently dropped. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationSettings` | application/json | The updated settings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/demo-data Get demo-data status for the active organization - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `DemoDataStatus` | application/json | The current demo-data status. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/organization/demo-data Create demo data in the active organization Creates one tagged, removable set. Requires the owner or admin role. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `DemoDataStatus` | application/json | The generated demo-data status. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### DELETE /api/v1/organization/demo-data Remove demo data from the active organization Deletes tagged rows only. Generated ancestors needed by real data are preserved and promoted. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `DemoDataCleanup` | application/json | What was deleted and preserved. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/domain Google Workspace domain of the active organization - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationDomain` | application/json | The domain claim. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/organization/domain Claim a Google Workspace domain - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `domain` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationDomain` | application/json | The unverified claim. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PATCH /api/v1/organization/domain Enable or disable automatic Google membership - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `autoJoinEnabled` | `boolean` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationDomain` | application/json | The updated claim. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/organization/domain Remove the organization domain claim - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The claim is absent. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/organization/domain/verify Verify the DNS challenge now - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationDomain` | application/json | The checked claim. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/features Read the organization's own feature switches Open to every member. Each key is `true` while the organization has the feature on, which is the default. This is the organization’s own choice only; whether a feature is in effect also depends on the operator, and `GET /me` `organizationFeatures` answers that. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationFeatureSwitches` | application/json | The switches. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PATCH /api/v1/organization/features Switch the organization's own features on or off Requires the `owner` or `admin` role and a signed-in session — never a token. Only the keys sent change; `{}` changes nothing, and repeating a switch is harmless. Several keys are written in one transaction. Switching a feature off hides it and refuses its routes, but **its data stays**: switching it on again brings everything back. Switching `absences` back on concerns every connected client approved in this organization with `thymian:planning.read`, which then reaches absences. By default its tokens are withdrawn: the client is answered 401 and asked to approve again, and its grant and switches stay. `planningReadConsent: extend` keeps them working instead. Either way each person concerned, other than the caller, is told by email. The write is session-only because the switch is attributed to the person who made it. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `planning` | `boolean` | no | | | `customer-portal` | `boolean` | no | | | `tickets` | `boolean` | no | | | `absences` | `boolean` | no | | | `planningReadConsent` | `"ask-again" \| "extend"` | no | What switching `absences` back on does to the connected clients approved with `thymian:planning.read`, which then reach absences. `ask-again` (the default) withdraws their tokens, so each is answered 401 and asked to approve again; `extend` lets their approval reach absences as it is. Either way the persons concerned, other than the caller, are told by email. Ignored unless absences actually come back on. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationFeatureSwitches` | application/json | The switches after the change. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/features/absences/planning-readers Read who is concerned when absences are switched back on Requires the `owner` or `admin` role and a signed-in session — never a token. The persons whose connected client was approved with `thymian:planning.read`, holds a live token and could read planning now: `planning` is on, and they are still members with the `owner`, `admin` or `planning` role. Switching `absences` back on lets those clients reach absences, so `PATCH /organization/features` asks with `planningReadConsent` whether they are asked to approve again or approved as they are. Empty while absences are on. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlanningReadersAwaitingAbsences` | application/json | The persons concerned. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/icon Get the active organization's icon Readable by any member; only an owner or admin may change it. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationIcon` | application/json | The organization's icon, or `null`. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/organization/icon Set the active organization's icon Replaces the icon, or sets the first one. Idempotent, hence PUT. Requires the `owner` or `admin` role. The image must arrive ready to store: a **square** PNG or WebP within the documented size limits — identical rule to a customer or project icon. The server neither crops nor resizes; the client does that (docs/decisions/0020-icons-are-normalised-in-the-browser.md). - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `contentType` | `"image/webp" \| "image/png"` | yes | Must match what the bytes actually are; a mismatch is rejected. | | `data` | `string` | yes | Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ResourceIcon` | application/json | The new icon. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/organization/icon Remove the active organization's icon Idempotent: an organization with no icon still answers 204. Requires the `owner` or `admin` role. - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | No icon is set. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/login-policy Whether the active organization requires Google-only sign-in - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationLoginPolicy` | application/json | The current policy. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PATCH /api/v1/organization/login-policy Require or stop requiring Google-only sign-in for organization members Enabling refuses (409) unless the organization domain is currently verified and the caller's own account already has a linked Google account — the evidence ADR-2026-08-17-08KG9 requires so an owner can never lock the organization out by activating this. Enabling revokes every other active session belonging to a member of the organization; the caller stays signed in. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `enabled` | `boolean` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationLoginPolicy` | application/json | The updated policy. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/member-avatars Avatars of the active organization Keyed by person id; each URL is the final profile picture selected by the server: a Timjami upload first, then the identity provider’s picture. A member without either is absent, so clients can draw a monogram. A separate endpoint rather than embedding this in better-auth’s `organization.listMembers()`: an avatar lives outside better-auth’s tables entirely. The canonical member-profile loader combines active membership, the identity-provider image and the profile-picture store in a fixed query count. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `MemberAvatars` | application/json | Avatars of the active organization’s members. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/member-google-accounts Which of the active organization’s members have a linked Google account A member absent from the list simply has none linked — useful for an owner weighing whether to enable the Google-only login policy without locking a colleague out by surprise (invariant argued in ADR-2026-08-17-08KG9). Like `member-avatars`, a separate round trip rather than embedding this in better-auth’s `organization.listMembers()`: the linked account lives outside better-auth’s membership tables, and a person can be in more than one organization. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `MemberGoogleAccounts` | application/json | User ids of the active organization’s members with a linked Google account. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/members The active organization’s roster One row per **person** this organization knows: the person id everything else here keys people by, the display name and email, and — when they hold one — the membership id a role change addresses, the role, and when they joined. `memberId`, `role` and `joinedAt` are `null` together for somebody with no access grant: a person a Harvest import created, or one whose membership has ended. They are on the roster because the records name them — hours, projects, reports — and `member` stays better-auth’s and stays the only grant there is (ADR-2026-08-31-08RPY). The web app reads its member pickers and the Team page from this instead of better-auth’s `organization.listMembers()` — the roster is a domain read since a person became a domain entity, and the name follows the person’s display rule rather than a raw account name. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationMembers` | application/json | The members of the active organization. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/members/{personId}/daily-report Get a member's daily report subscription Requires member.manage. Returns only the three report preferences of a current member in the active organization. The recipient can also change these in their own profile. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `MemberDailyReport` | application/json | The daily report settings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/organization/members/{personId}/daily-report Update a member's daily report subscription Requires member.manage. An enabled result requires current recipient membership and report.readOthers, including schedule-only changes. Support sessions cannot enable a subscription. Delivery uses the account email and current recipient permissions; no other preferences are accessible. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `dailyOrgReportEmailEnabled` | `boolean` | no | | | `dailyOrgReportSendWeekdays` | `integer[]` | no | | | `dailyOrgReportSendHour` | `integer` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `MemberDailyReport` | application/json | The updated daily report settings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/organization/running-timers Who in the active organization is currently running a timer For the header's running-timer avatars — presence only, deliberately never the rate or the amount `GET /time-entries?running=true` carries for the same rows. Everyone with a running timer is listed. What they work on is subject to the same visibility as everywhere else time entries are read (invariant 51): a colleague's entry on a private project you cannot see appears with `task` and `project` `null` — that somebody is working, and for which customer (customers are not private), is not secret; what exactly they work on is. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationRunningTimers` | application/json | Presence of everyone currently running a timer. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/settings Get settings of the active organization Created with defaults on first access, so this never 404s for an organization you belong to. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationSettings` | application/json | The settings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PATCH /api/v1/organization/settings Update settings of the active organization Requires the `owner` or `admin` role. `hoursPerDay` is organization-wide and deliberately not overridable per user: two members must never see different day rates for the same time entry (ADR 0012). `hintRetentionDays` bounds how long observations reported by collectors are kept. Shortening it does not delete anything by itself — expired hints go when their user next reports one, because there is no scheduler (ADR 0025). Changing `currency` does not convert existing amounts — each time entry carries the currency it was recorded in. Changing `tourEnabled` or `jamiEnabled` to `true` answers 403 `feature-not-enabled`, naming the feature, while the organization does not have that feature in effect; for `jamiEnabled`, either `jami` or `jami-notes` in effect is enough. Sending the value already stored is accepted unchanged, and switching either off is always accepted (ADR-2026-09-28-12X97, ADR-2026-09-30-17KCB). Changing `tourEnabled` from `false` to `true` offers the tour to the members who have no progress in it yet, in the same transaction: they already use the application, so they are asked rather than walked through it. - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | no | ISO-4217 currency code. | | `rateDisplay` | `"hourly" \| "daily"` | no | How rates are presented in the UI. Does not affect stored data. | | `hoursPerDay` | `string` | no | | | `timeZone` | `string` | no | | | `weekStartsOn` | `integer` | no | | | `hintRetentionDays` | `integer` | no | Days a hint is kept before `POST /hints` prunes it. Enforced when a collector reports, not by a scheduler. | | `runningTimerAvatarsEnabled` | `boolean` | no | | | `tourEnabled` | `boolean` | no | | | `jamiEnabled` | `boolean` | no | | | `llmHintEvaluationEnabled` | `boolean` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationSettings` | application/json | The updated settings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/organization/tracking-window Set how far back the organization records time Requires the `owner` or `admin` role and a signed-in session — never a token. `trackingOpenMonths` counts the **completed months that stay open beside the current one**: `2` in August keeps June and July editable in full, on the 1st and on the 31st alike, and closes May. `null` removes the limit, which is the default — an organization that never sets it is unaffected by this rule entirely. The limit is conjunctive with each project’s `closedThroughDay` and never replaces it: a write needs both to allow it. Owners and admins may still book or correct a single entry behind the limit per request with `editReason`, which lands on that entry’s revision. **Absences have no such override**, for anybody: they keep no history, so a retroactive change would owe a reason with nowhere to live. Nothing is recalculated, deleted or moved when the limit changes, and no invoice, total or budget figure is affected — this decides what may still be *written*, nothing else. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `trackingOpenMonths` | `integer \| null` | yes | `null` removes the limit; the setting is off by default. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationSettings` | application/json | The updated settings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Customers Customers of the active organization. ### GET /api/v1/customers List customers Customers of the active organization, alphabetical. Archived ones are excluded by default. Paginate with `cursor` set to the previous page’s `nextCursor`, unchanged; omit `limit` for every matching customer in one call. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `includeArchived` | `string` | no | | | `search` | `string` | no | Case-insensitive substring match against the customer's name, code or aliases. | | `limit` | `integer` | no | | | `cursor` | `string` | no | Keyset cursor: pass the previous page's `nextCursor` back unchanged. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `CustomerList` | application/json | The customers. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/customers Create a customer - Scope: `thymian:customers.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `code` | `string \| null` | no | | | `color` | `string` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `Customer` | application/json | The created customer. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### GET /api/v1/customers/{id} Get a customer - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Customer` | application/json | The customer. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/customers/{id} Update a customer - Scope: `thymian:customers.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `code` | `string \| null` | no | | | `color` | `string` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Customer` | application/json | The updated customer. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/customers/{id}/archive Archive or restore a customer Customers are archived rather than deleted, because their projects may carry tracked time that must stay attributable. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `archived` | `boolean` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Customer` | application/json | The customer. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/customers/{id}/icon Set a customer's icon Replaces the icon, or sets the first one. Idempotent, hence PUT. The image must arrive ready to store: a **square** PNG or WebP within the documented size limits. The server neither crops nor resizes — the client does that, so the person uploading sees exactly what will be stored (docs/decisions/0020-icons-are-normalised-in-the-browser.md). - Scope: `thymian:customers.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `contentType` | `"image/webp" \| "image/png"` | yes | Must match what the bytes actually are; a mismatch is rejected. | | `data` | `string` | yes | Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Customer` | application/json | The customer, with its new icon. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/customers/{id}/icon Remove a customer's icon Idempotent: a customer that has no icon still answers 204. A 404 means the customer itself is unknown. - Scope: `thymian:customers.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The customer has no icon. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/customers/{id}/period Close a tracking period for every project of a customer The same movement `POST /projects/{id}/period` makes, applied to every project of this customer in one transaction — archived ones included, because archiving governs what may be *booked*, never what may be billed. `projectIds` narrows it to some of them, still in one transaction: the projects a recurring invoice bills, when its rule covers only those. Projects already standing on the stated day are reported as `moved: false`, which is the ordinary case rather than a failure. If **any** of them would move backwards, the whole movement is an opening: it needs `period.open` and a `reason`, and the reason is stored on every project that actually moved. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `closedThroughDay` | `string \| null` | yes | The last day to close, inclusive — `null` opens everything again. A day later than the one stored is a **close** (owner, admin or `billing`); an earlier one, `null` included, is an **opening** (owner or admin) and needs `reason`. | | `reason` | `string` | no | Why the period is being opened again. **Required** — 422 with `reasonRequired` — for any backwards movement, and ignored for a close: closing is a statement of completeness, and the completeness is the reason. Kept with the movement, and shown in the project’s history. | | `projectIds` | `string (uuid)[]` | no | Only these projects of the customer, still in one transaction. Omitted means every project of the customer; an id that is not one of them is 422. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PeriodCloseResult` | application/json | What moved, per project. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/customers/{id}/suggested-colors Suggest colours from a customer's icon Ranked colours extracted from the customer's stored icon, best first. 404 when the customer has no icon; an unsupported icon format or one with no usable colour answers an empty `suggestions` list rather than a 404. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ColorSuggestions` | application/json | The ranked suggestions. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/customers/setup Set up a customer with its first project and billing Creates the customer and, as asked, its first project with tasks, people and rates, a recurring billing rule over all of its projects, and the Easybill mapping — in one transaction, so any refusal leaves nothing behind. Every refusal names the body field it is about (`customer.name`, `project.tasks.2.name`, `billing.responsiblePersonIds`). `billing` needs `invoice.write` and `easybill` needs `easybill.map`; without them the request is refused with 403 naming the field, before anything is written. The Easybill customer is read from Easybill before the transaction; when that read fails, everything else is still created and `easybill` in the answer says why the mapping is missing. You are assigned to the project. A rate template copies the version of its rates in force today as one version from the beginning; what cannot be copied is listed in `droppedRateRules`. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customer` | `object` | yes | | | `customer.name` | `string` | yes | | | `customer.aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `customer.code` | `string \| null` | no | | | `easybill` | `object` | no | The Easybill customer to map, read back from Easybill before anything is written. Needs `easybill.map`. When the read fails, everything else is still created and `easybill` in the answer says why the mapping is missing. | | `easybill.easybillCustomerId` | `string` | yes | | | `project` | `object` | no | The first project. | | `project.name` | `string` | yes | | | `project.aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `project.visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `project.hourlyRateMinor` | `integer` | no | The project’s default hourly rate in minor units, as one rule from the beginning. Not together with `rateTemplateProjectId`. | | `project.rateTemplateProjectId` | `string (uuid)` | no | A project whose rates are copied: only the version in force today, as one version from the beginning. Its default rule is copied; a task rule moves to the new task of the same name (case-insensitively) and is left out without one; a person rule is left out for a former member — somebody whose membership ended or whose account was deleted — and kept for a colleague who never had an account; a budget rule never. Everything left out is listed in `droppedRateRules`. 404 when you cannot see the project. | | `project.tasks` | `object[]` | no | The tasks to create. No two may share a name, compared case-insensitively. | | `project.personIds` | `string (uuid)[]` | no | The people to assign. You are assigned anyway, named here or not. | | `billing` | `object` | no | A recurring billing rule for all of the customer’s projects. Needs `invoice.write`. Older hours are left for later — a new customer has none — so the rule produces no proposal before its first period ends and can be deleted until then. | | `billing.name` | `string` | yes | A human-readable name for the rule. | | `billing.recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `billing.dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `billing.selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `billing.defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `billing.firstPeriodStart` | `string` | no | The first period, normalized to a quarter start when `recurrence` is `quarterly`. Defaults to the current month; a period that has already ended is refused. | | `billing.responsiblePersonIds` | `string (uuid)[]` | no | The people reminded when a proposal is due. Defaults to you. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CustomerSetupResult` | application/json | What was created. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/customers/setup/templates/{projectId} Read what setting a customer up like a project fills in The tasks, people and privacy „Einrichten wie …" fills in from a project for `POST /customers/setup`. The people leave out former members — somebody whose membership ended or whose account was deleted — exactly as the setup leaves out their person rates when the project is its `rateTemplateProjectId`. 404 for a project you cannot see. An archived project answers like any other. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `CustomerSetupTemplate` | application/json | What the project fills in. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/customers/suggested-colors Apply a suggested colour to many customers at once For each customer: extracts colours from its icon and applies the best suggestion, unless the customer already has an explicitly chosen colour (`colorSource: "manual"`), which is skipped rather than overwritten. - Scope: `thymian:customers.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerIds` | `string (uuid)[]` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BatchSuggestColorsResult` | application/json | What happened, per customer. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Projects Projects, their billing rules and assigned members. ### GET /api/v1/projects List projects Sorted by last activity: a running timer first, then the most recent tracked time, a project nobody has ever tracked time on last. `orderBy=customer` sorts by customer name and then project name instead, so a client can group the page by customer without a group ever being split across two pages. Paginate with `cursor` set to the previous page’s `nextCursor`, unchanged — a cursor belongs to the order that issued it. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `customerIds` | `string` | no | Comma-separated customer ids. A single-value query param is ambiguous between "one id" and "one id, repeated" across HTTP clients, so this is always a delimited string rather than a repeated key. | | `includeArchived` | `string` | no | | | `includeUnbilled` | `string` | no | Add the project’s `unbilled` slice. Off by default: it is computed over the whole organization’s open time, so a picker that only needs names should not pay for it. | | `visibility` | `"public" \| "private"` | no | | | `unassigned` | `string` | no | Only projects with no members at all — invariant 51’s "unbesetzt". | | `mine` | `string` | no | Only projects you are a member of. | | `search` | `string` | no | Case-insensitive substring match against the project’s own name/aliases or its customer’s name/aliases. | | `orderBy` | `"activity" \| "name" \| "customer"` | no | | | `limit` | `integer` | no | | | `cursor` | `string` | no | Keyset cursor: pass the previous page's `nextCursor` back unchanged. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The projects.`{ data: ProjectListItem[], nextCursor: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/projects Create a project `rules` is what an hour on this project is worth. Each rule names zero or more axes exactly — a task, a person, a budget — and carries a rate from a month; the most specific one matching an entry decides, and work no rule matches is billable with no rate, which is reported rather than filled in. A rule cannot name a budget here: a project that has just been created is in none yet, so every budget rule would price nothing. Add the project to a budget first, then `PUT /projects/{id}/rate-rules`. Omit for none. Afterwards the list is replaced whole through `PUT /projects/{id}/rate-rules`. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `rules` | `WriteRateRule[]` | no | | | `visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `color` | `string \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `Project` | application/json | The created project. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/projects/{id} Get a project Carries the same money/activity aggregates the list screen shows — see `projectDetailSchema` — so the detail page needs no second, per-project resolver (invariant 1). The unbilled slice is added only with `includeUnbilled=true`. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `includeUnbilled` | `string` | no | Add the project’s `unbilled` slice. Off by default: it is computed over the whole organization’s open time, so a picker that only needs names should not pay for it. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ProjectDetail` | application/json | The project. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/projects/{id} Update a project Rules are **not** editable here, the way a budget’s adjustments are not editable through `PATCH /budgets/{id}`: use `PUT /projects/{id}/rate-rules`, so one form saving a name can never silently rewrite what an hour is worth. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `color` | `string \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Project` | application/json | The updated project. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/projects/{id}/archive Archive or restore a project - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `archived` | `boolean` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Project` | application/json | The project. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/projects/{id}/icon Set a project's icon Replaces the icon, or sets the first one. Idempotent, hence PUT. The response's `icon` is the project's *resolved* icon — this upload if it is accepted, its customer's otherwise (ADR-2026-08-18-13UMG). `iconInheritedFrom` says which. - Scope: `thymian:project-icons.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `contentType` | `"image/webp" \| "image/png"` | yes | Must match what the bytes actually are; a mismatch is rejected. | | `data` | `string` | yes | Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Project` | application/json | The project, with its new icon. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/projects/{id}/icon Remove a project's icon Idempotent: a project with no icon still answers 204. Afterwards `GET`/`PATCH` on the project answer the customer's icon again, with `iconInheritedFrom` naming it (ADR-2026-08-18-13UMG). - Scope: `thymian:project-icons.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The project has no icon. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/projects/{id}/members List project members Who is assigned to the project. An assignment is what puts the project in somebody’s pickers; it does **not** decide what their hour is worth — that is a rule naming them, so unassigning a colleague never reprices the hours they booked. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The project members.`{ data: ProjectMember[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/projects/{id}/members Assign a person to a project Idempotent: assigning an already-assigned person returns the existing assignment. PUT rather than POST for that reason. Any person **of this organization** may be named, whether or not they still have access: somebody a Harvest import created without a login, or a colleague whose membership has ended, is assignable like anybody else. An assignment says whose hours belong on this project, which is a statement about the work and not a grant — a person with no live membership cannot access this organization, so it hands them nothing (ADR-2026-09-22-09MER). - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ProjectMember` | application/json | The project member. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/projects/{id}/members/{personId} Remove a person from a project - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The assignment was removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/projects/{id}/period Close a tracking period, or open one again Moves this project’s `closedThroughDay`. Hours on a day at or below it take no new bookings, no corrections and no deletions — the day an entry’s `startedAt` falls in decides, in the organization’s time zone, so an entry that begins at 23:55 on the last closed day is inside the period whatever hour it ends in. Forwards is a **close**: owner, admin or `billing`, no reason. Backwards — `null` included — is an **opening**: owner or admin, and `reason` is required (422 with `reasonRequired` without one). Every movement is logged with its actor and is readable at `GET /projects/{id}/period-changes`. **A timer still running inside the period refuses the close** (409, `runningEntryIds`): hours are not final while a clock is ticking in them. Stop it and close afterwards. Owners and admins may still correct a single entry inside a closed period per request, with `editReason` — the watermark is moved back only when broad access *is* the intent. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `closedThroughDay` | `string \| null` | yes | The last day to close, inclusive — `null` opens everything again. A day later than the one stored is a **close** (owner, admin or `billing`); an earlier one, `null` included, is an **opening** (owner or admin) and needs `reason`. | | `reason` | `string` | no | Why the period is being opened again. **Required** — 422 with `reasonRequired` — for any backwards movement, and ignored for a close: closing is a statement of completeness, and the completeness is the reason. Kept with the movement, and shown in the project’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PeriodCloseResult` | application/json | What moved. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/projects/{id}/period-changes Who closed and opened this project’s periods Every movement of `closedThroughDay`, newest first — actor, time, from, to, and the reason a backwards movement had to state. Append-only: a movement is never edited or deleted, the way a time entry’s revisions are not. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The movements, newest first.`{ data: PeriodCloseChange[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/projects/{id}/rate-rules List a project’s billing rules Every version, least specific first then by month. Group by `validFromMonth` to get the versions: from a version’s month onwards only its own rules apply. A project with no rules prices nothing on it. `exposureByMonth` is what a change to a version would revalue — tracked seconds per month, and how many of them a live invoice claims. A version prices from its month forward, so summing from that month on is the figure the editor states before saving, and a non-zero invoiced sum is what `PUT` refuses for a member. `pricingBudgets` is what a rule may name on the budget axis: the budgets covering this project, plus everything downstream of them by overflow — the pots its hours can actually land in. Naming anything else is refused, because such a rule prices nothing. `namedBudgets` is the other half: the pots this project’s rules already name that a *new* rule may no longer name, because they were archived or their chain no longer reaches here. Such a rule stays stored and prices nothing, so it is carried here for its name alone — a rule nobody can read is a rule nobody can decide to delete. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `RateRules` | application/json | The project’s rules. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/projects/{id}/rate-rules Replace a project’s billing rules The whole list — every version — in one transaction. „Ab Oktober: Standard 100, Design 120, Anna 130" is one version somebody agreed, and a half-applied billing change is exactly the state the rules exist to prevent. Sending everything is also what lets one request correct an old version and add a new one. A version is the set of rules sharing a `validFromMonth`, and it **replaces** its predecessor: from its month onwards only its own rules apply, so a rule it does not restate has ended. That is how „ab Juni hat diese Aufgabe keinen Sonderpreis mehr" is said at all. Rules that survive keep their ids. Within one version, two rules neither of which is a special case of the other — „Aufgabe = Design" against „Person = Anna" — are refused with 422: there is no derivable answer for Anna’s Design hour, so the case has to be spelled out as a third rule naming both. Rules in *different* versions never conflict, because they never both apply. A rule may name a **budget**, which prices the hours the allocation put in that pot — and only a budget that can hold this project’s hours: the ones covering it, plus anything downstream of them by overflow (`pricingBudgets` on the `GET`). Anything else is 422, because such a rule would match nothing at all. Nothing about a time entry is frozen until an invoice bills it, so editing the list does change what existing entries are worth — which is the point of the dating. A version that has already priced hours **an invoice claims** is therefore refused with 409 for a member, naming the way forward; the `owner` and `admin` roles pass, and their correction is listed by `GET /invoices/divergences`. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `rules` | `WriteRateRule[]` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `RateRules` | application/json | The project’s rules, after the change. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/projects/{id}/write-offs Write off this project’s open hours and allowance months Records that this project’s currently open time entries and monthly allowance months will never be billed, and why. The cut and the refusals are the preview’s; this is that request with a reason attached. Writes each family through its own existing mechanism — `POST /write-offs` and `POST /write-offs/allowance` — in **two separate transactions**, entries first. On the rare race where the allowance half loses one that the preview could not have seen a moment earlier, the entries half **stays written off** and the response is a 409 naming the write-off id that already committed, so nothing is silently lost. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | no | Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. | | `until` | `string` | no | Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. | | `reason` | `string` | yes | Why these hours and months will never be billed. Required, for the same reason `CreateWriteOff.reason` is. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CreatedProjectWriteOff` | application/json | What was waived, per family. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/projects/{id}/write-offs/preview What writing off this project’s open hours and allowance months would waive Writes nothing. Resolves, server-side, exactly the two things „Abzurechnen" already counts for this project — its open, finished time entries and its open, unclaimed monthly allowance months — and previews each through the same `POST /write-offs/preview` and `POST /write-offs/allowance/preview` arithmetic unchanged. Either half is `null` when the project has nothing open in that family within `from`/`until`. The same refusals as the two previews it delegates to: a **running** entry never blocks this (it is excluded from the resolved selection rather than refused), but **unpriced** seconds or an unpriced month’s share still 422, naming the project and task, exactly as the id-based and month-based dialogs already refuse them — a project write-off claims no more, and under no looser rule, than either already allows. 404 for a project this organization does not have or may not see. 422 when the window has nothing open at all, entries and allowance months alike. 409 on the rare race where an allowance month is settled by somebody else between resolving candidates and pricing them. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | no | Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. | | `until` | `string` | no | Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ProjectWriteOffCut` | application/json | What this project’s open work would waive. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/projects/setup Set up a project with its tasks, people and rates Creates a project for an existing customer with tasks, people and rates — in one transaction, so any refusal leaves nothing behind. Every refusal names the body field it is about (`customerId`, `project.name`, `project.tasks.2.name`). You are assigned to the project. A rate template copies the version of its rates in force today as one version from the beginning; what cannot be copied is listed in `droppedRateRules`. A billing rule over all of the customer’s projects covers the new project too, as it does after `POST /projects`. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | The customer the project is for. 404 when you cannot see it. | | `project` | `object` | yes | The project. | | `project.name` | `string` | yes | | | `project.aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `project.visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `project.hourlyRateMinor` | `integer` | no | The project’s default hourly rate in minor units, as one rule from the beginning. Not together with `rateTemplateProjectId`. | | `project.rateTemplateProjectId` | `string (uuid)` | no | A project whose rates are copied: only the version in force today, as one version from the beginning. Its default rule is copied; a task rule moves to the new task of the same name (case-insensitively) and is left out without one; a person rule is left out for a former member — somebody whose membership ended or whose account was deleted — and kept for a colleague who never had an account; a budget rule never. Everything left out is listed in `droppedRateRules`. 404 when you cannot see the project. | | `project.tasks` | `object[]` | no | The tasks to create. No two may share a name, compared case-insensitively. | | `project.personIds` | `string (uuid)[]` | no | The people to assign. You are assigned anyway, named here or not. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `ProjectSetupResult` | application/json | What was created. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ## Tasks Tasks within a project. ### GET /api/v1/tasks List tasks Visible, not necessarily bookable: a public project’s tasks are readable by everybody, but starting a timer or logging time against one still answers 403 unless the caller is assigned to its project (or holds `project.writeAny`). `bookable=true` asks the narrower question up front — the same one `GET /tasks/recent` already answers for the Schnellstart band — so a picker can leave unbookable tasks out instead of offering them and reporting the 403 on submit. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `includeArchived` | `string` | no | | | `bookable` | `string` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The tasks.`{ data: Task[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/tasks Create a task `isBillable: false` wins over every rule: entries on such a task are never billable, whatever the project’s rules say. A rate *for* this task is a rule on its project naming it — `PUT /projects/{id}/rate-rules`. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | no | The billing mode to set. `fixed_price` needs `fixedPriceAgreementId` in the same request or already on the task, and `hourly` and `productive` forbid one. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === "hourly"`), otherwise the write answers 422. | | `fixedPriceAgreementId` | `string (uuid) \| null` | no | The fixed-price agreement this task’s hours are spent on. Its customer must be the task’s customer. `fixed_price` requires one, `hourly` and `productive` forbid one, `internal` may carry one. | | `isBillable` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. Whether hours on this task are priced by the hour. The task is `hourly` by default, which applies only when `billingMode`, `isBillable` and `isProductive` are all omitted; sending `isProductive: true` alone creates a `productive` task. | | `isProductive` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. Derived: `billingMode === "productive"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `isCatchAll` | `boolean` | no | Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `Task` | application/json | The created task. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/tasks/{id} Get a task - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Task` | application/json | The task. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/tasks/{id} Update a task Rates are **not** editable here — they live on the project as rules, so saving a name can never silently rewrite what an hour is worth. `isBillable` is the one field that revalues existing hours: billability is undated and comes from the task, so toggling it bills or unbills every one of that task’s un-invoiced entries, in every month at once. Once an invoice claims any of them it is therefore refused with 409 for a member, whose way forward is a new task; the `owner` and `admin` roles pass, and their change is listed by `GET /invoices/divergences`. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | no | The billing mode to set. `fixed_price` needs `fixedPriceAgreementId` in the same request or already on the task, and `hourly` and `productive` forbid one. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === "hourly"`), otherwise the write answers 422. | | `fixedPriceAgreementId` | `string (uuid) \| null` | no | Attach the task to a fixed-price agreement, or `null` to detach it. `fixed_price` requires one, `hourly` and `productive` forbid one, and `internal` may carry one. Neither this nor `billingMode` changes once the task has entries, for any task that has or would get an agreement: answers **409**, with no owner or admin exception. | | `isBillable` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. `true` makes the task `hourly`. `false` makes it `internal`, or leaves it `productive` when it already is. | | `isProductive` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. Derived: `billingMode === "productive"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `isCatchAll` | `boolean` | no | Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Task` | application/json | The updated task. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/tasks/{id} Delete a task Only a task nothing records history on: it answers **409** while tracked time, a monthly allowance or an invoice names it — including an entry that was booked and deleted again, which the revision log still shows under the task. Archive such a task instead (ADR-2026-10-18-14CLD). Rate rules, hint rules and Harvest links that name the task go with it. Deleting a fixed-price agreement’s last task is what lets the agreement be deleted. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The task is deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/tasks/{id}/archive Archive or restore a task - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `archived` | `boolean` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Task` | application/json | The task. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/tasks/bookable List every task the caller may book, ordered by personal frecency The complete read model for a manual task picker: every live task the caller may book, with customer/project context and resolved visuals in one response. Rows are ordered by the caller’s decaying booking frequency, then recency. A task with no contributing history remains present and falls back to deterministic customer, project and task name order. Clients keep this order while filtering `searchText` locally; the endpoint takes no search query and has no result limit. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Every task the caller may book, in picker order.`{ data: BookableTask[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/tasks/recent List the tasks the caller books time on, most recent first What the day screen offers as one-click starts. Ordered by the start of the caller's most recent entry on each task, descending. The rate fields are the rate **a timer started now would freeze** — resolved from the project’s rules by the same function `POST /time-entries/start` uses, not read from the last entry. A task whose rules changed since it was last booked reports the new answer. Resolved against the **current** month of the organization’s zone, because that is the month a press right now would land in. A rule row that starts next month is therefore not what a tile advertises today. Always the caller's own entries: this describes what one person was working on. Archived tasks and archived projects are left out. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `days` | `integer` | no | | | `limit` | `integer` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The tasks, most recent first.`{ data: RecentTask[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/tasks/suggested-names Suggest names for new tasks from the names already in use The task names the organization’s projects already carry, the name most projects carry first — what a form creating tasks offers as one-click chips. Names are compared case-insensitively and come back in their most common spelling, with the value state (`isBillable`, `isProductive`) most tasks of that name carry. Ranked by the number of distinct projects carrying the name, then by the most recently created task with it, then by name. Only tasks the caller can see, on live projects of live customers, and never a catch-all task. No time entry is read: a name counts by where it is used, not by how much time it holds. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `prefix` | `string` | no | Only names starting with this text, case-insensitively. `%`, `_` and `\` are taken literally. | | `projectId` | `string (uuid)` | no | Leave out every name this project already carries, archived tasks included — a project cannot hold a name twice. | | `exclude` | `string[]` | no | Names the caller already holds, compared case-insensitively; repeat the key for several. They are left out so `limit` new suggestions still come back. | | `limit` | `integer` | no | How many suggestions to answer at most. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The suggestions, most common first.`{ data: TaskNameSuggestion[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Icons Customer and project images, served from signed URLs so they can be used directly as an image source. ### GET /api/v1/icons/{token} Serve a customer or project icon Returns the image bytes for a signed token, as found in a resource’s `icon.url`. Deliberately not authenticated with a session or a bearer token: the URL is meant to be usable directly as an image source. The token is signed, names the exact bytes it grants access to, and expires — so it stops working when the icon is replaced or removed. Do not build these URLs; read them from the resource. Do not store them either — a stale one answers 404, which is the signal to refetch the resource. An image stored as WebP is sent as WebP only when `Accept` names `image/webp` explicitly; a wildcard such as `*/*` gets it re-encoded as PNG, because some clients (watchOS) send only wildcards and cannot decode WebP. A stored WebP that cannot be re-encoded is sent as WebP regardless of `Accept`. An image stored as PNG is always sent as PNG. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `token` | `string` | yes | Opaque signed token from the resource’s `icon.url`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | image/webp, image/png | The image. | | 304 | — | — | Not modified — the client already has these bytes. | **Problems** (RFC 9457 `application/problem+json`): 404. ## Time entries Tracking time, including the running timer. ### GET /api/v1/time-entries List time entries Newest first. `orderBy: 'startedAt'` (the default) sorts by `startedAt` then `id`; `orderBy: 'updatedAt'` sorts by `updatedAt` then `id` instead, regardless of when the hours fall — page from the top, compare `updatedAtPrecise` (never `updatedAt` itself, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes. `orderBy: 'updatedAt:asc'` is the same column, oldest write first, and its `nextCursor` is a durable watermark rather than a within-poll stopping point: keep the last one you received and pass it back as `cursor` on your next poll to read only what changed since, instead of paging from the top again. All three are best-effort sorts, not a linearizable change feed, so a write that commits later than an already-observed watermark but is stamped earlier can still be missed, and none of them surface deletions, which `GET /time-entries/deleted` covers separately. Paginate with `cursor` set to the previous page's `nextCursor`, unchanged, together with the same `orderBy` — the cursor names both sort columns, so rows sharing an instant are not skipped at a page boundary; offsets are unstable while a timer is running. Omitting `personId` returns the whole organization's entries, readable by every member; naming a *specific other person's* `personId` requires the `owner` or `admin` role. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Filter by person. Defaults to everybody’s. Naming a specific *other* person requires the owner or admin role. | | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `taskId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `budgetId` | `string (uuid)` | no | Filter to the projects a budget covers. Narrows projects only — a budget's own figure additionally counts billable, finished entries inside its month, which is `isBillable=true` plus `from`/`until`. | | `from` | `string (date-time)` | no | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | no | Exclusive upper bound on startedAt. | | `isBillable` | `string` | no | | | `externalKey` | `string` | no | Only entries carrying a live reference to this ticket key, read in the spelling its system uses — `eng-1` finds `ENG-1`. Combine with `externalProvider` to name one ticket, since a key alone can exist in two systems. | | `externalProvider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | no | Only entries carrying a live reference in this ticket system. | | `hasExternalReference` | `string` | no | true = only entries with at least one live reference, false = only entries with none. | | `running` | `string` | no | true = only running entries. | | `harvestExport` | `"synced" \| "pending"` | no | Filter by Harvest sync state. 'synced' means Harvest holds the entry, whether Timjami exported it or imported it. 'pending' includes an entry never claimed and one whose claim is still in flight — neither has actually reached Harvest. | | `orderBy` | `"startedAt" \| "updatedAt" \| "updatedAt:asc"` | no | 'startedAt' (the default) is the ordinary list, newest tracked time first. 'updatedAt' orders by when an entry was last written instead, regardless of when its hours fall, and issues a different `nextCursor` shape that only this mode accepts back. To sync this way: page from the top (no `cursor`) on every poll, compare `updatedAtPrecise` (not `updatedAt`, which is millisecond-rounded and can tie two writes), and reprocess every row equal to the last value the previous poll saw before stopping below it. 'updatedAt:asc' is the same column, oldest write first, with its own `nextCursor` shape again — but unlike the other two, that cursor is a durable watermark: page forward once to the end, keep the last `nextCursor` you received, and pass it back as `cursor` on your *next* poll (whenever that is) to read only what has changed since, without paging from the top again. This is still a best-effort sort, not a linearizable change feed: `updated_at` is stamped when a write starts rather than when it commits, so a slow transaction can commit a value below a watermark a caller has already moved past and be missed permanently — periodic full re-reads from the top remain the only way to close that gap, and hard-deleted entries never appear under any `updatedAt` order at all; watch `GET /time-entries/deleted` separately for deletions. | | `limit` | `integer` | no | | | `cursor` | `string` | no | Keyset cursor: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeEntryList` | application/json | The time entries. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/time-entries Log a finished time entry For entering time after the fact. Both `startedAt` and `endedAt` are required — to start a running timer use POST /time-entries/start. An archived task — or one whose project or customer is archived — answers **409** with `archived` naming the level, however long ago the entry itself is dated. A day at or below the project’s `closedThroughDay` answers **409** with `periodClosed` naming the day and the watermark: the tracking period is closed and takes no new hours. Owners and admins may book anyway and then **must say why** (`editReason`, else **422** with `reasonRequired`). The day is the one `startedAt` falls in, in the organization’s time zone — an entry that begins at 23:55 on the last closed day is inside the period whatever hour it ends in. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | yes | When the work started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time)` | yes | When it ended. May equal `startedAt`, which records an entry of no length; earlier than it is rejected. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `notes` | `string \| null` | no | | | `editReason` | `string` | no | Why hours are being booked into a **closed** tracking period anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the day this entry starts in is at or below its project’s `closedThroughDay` and the request gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other booking. It is kept with the revision this write appends, readable in the entry’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `TimeEntry` | application/json | The created entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/time-entries/{id} Get a time entry - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeEntry` | application/json | The time entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/time-entries/{id} Update a time entry Times, notes and task can be corrected — an entry on an archived task, or on a project you are not assigned to, included, as long as the request does not *move* it onto such a task, which answers **409** (archived) or **403** (not assigned) the way booking on it does. Sending the entry’s current `taskId` unchanged is not a move. Giving a **running** entry an `endedAt` stops it, at that time — which is how "I went to lunch at 12:00 and forgot to stop the timer" is recorded in one step rather than by stopping now and correcting afterwards. `endedAt: null` turns a finished entry back into a running one. The rate snapshot cannot be edited — it is the record of what was agreed when the work happened (ADR 0010). An entry an invoice is currently billing answers **409** for `startedAt`, `endedAt` and `taskId` — the facts its line was built from — the way deleting one already does. Its `notes` stay editable. Cancelling and deleting the invoice releases the hours and the correction alike. That **409** carries `claimed` — the kinds holding the seconds, `invoice`, `external` and/or `write-off`, in that order — so a client can tell it from the archived-task **409** above and name the state in its own words instead of printing this one. Owners and admins (`time.editBilled`) may change those facts anyway, and then **must say why**: without `editReason` the request is **422** naming that field and carrying `reasonRequired: true`. The reason is kept with the revision this write appends — visible in the entry’s history and beside the entry in `GET /invoices/divergences`, which is where the correction shows up. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | no | Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time) \| null` | no | `null` turns a finished entry back into a running one. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `notes` | `string \| null` | no | | | `editReason` | `string` | no | Why hours that are already billed are being changed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the request changes `startedAt`, `endedAt` or `taskId` on an entry a live claim is holding, or moves such a fact into or out of a **closed tracking period**, and gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other save. It is kept with the revision this write appends, readable in the entry’s history and beside the entry in `GET /invoices/divergences`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeEntry` | application/json | The updated entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/time-entries/{id} Delete a time entry Deleting hours out of a **closed** tracking period is refused with **409** and `periodClosed`, the way booking them into one is: an emptied day changes the period as much as a filled one. Owners and admins may delete anyway and then must say why — `editReason`, a query parameter because a `DELETE` carries no body — else **422** with `reasonRequired`. The sentence is kept on the `deleted` revision, which is the last thing this entry’s history says. An entry a live claim holds cannot be deleted by anybody (409), including an owner. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `editReason` | `string` | no | Why hours are being deleted out of a **closed** tracking period anyway. Required — 422 with `reasonRequired` — when the caller gets through only because they hold `time.editBilled`; ignored otherwise. Kept on the `deleted` revision. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The entry was deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/time-entries/{id}/references List the tickets attached to a time entry Live references only, oldest attachment first. A detached one is gone from this list the moment it is detached — the tombstone it leaves is what the entry’s history reads, not this. Every member may read the references of an entry they can read, which is the same reach the entries list has (invariant 51). The list is also embedded in every `TimeEntryWithContext`, so a client rendering a list of entries never needs this endpoint per row. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The tickets on this entry.`{ data: ExternalReference[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/time-entries/{id}/references Attach a ticket to a time entry **Idempotent by `(provider, key)`.** Attaching what is already attached answers **200** with the reference that is there and writes nothing — two clicks on one link are one act. A new attachment answers **201**. Allowed on hours an invoice already holds and inside a closed tracking period, **without a reason**: a reference changes neither a second nor a rate nor a task, so nothing a freeze protects can move under it (invariants 17, 64 and 73). The authority is the authority to edit the entry, and nothing further. Timjami never calls the ticket system. `url` and `title` are stored exactly as supplied, the title is a label that is allowed to go stale, and the server checks the **form** of both — `https` and a single-token key — and deliberately not that `provider` agrees with the URL’s host: a self-hosted Jira or Azure DevOps Server lands on a host nothing here can enumerate. **While the organization has tickets switched off** this answers **202** with `{ "attached": false }` and stores nothing. A client released before the switch attaches its ticket right after starting a timer, and that start should read as it always did; the other ticket routes answer `403 feature-not-enabled` (`tickets`). - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | yes | | | `key` | `string` | yes | The ticket’s identifier. Trimmed; a key containing whitespace is refused rather than collapsed, because no tracker issues one and accepting it would make two references to the same ticket look different forever. | | `url` | `string \| null` | no | Where the ticket lives. Must be `https://`; `http://` and every other scheme are refused, because this is stored once and rendered as a link for the whole organization. | | `title` | `string \| null` | no | A label for the ticket. Trimmed; blank is stored as `null`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ExternalReference` | application/json | This ticket was already attached. | | 201 | `ExternalReference` | application/json | The ticket was attached. | | 202 | `object` | application/json | Tickets are switched off for this organization; nothing was stored.`{ attached: false }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/time-entries/{id}/references/{referenceId} Detach a ticket from a time entry The row is **tombstoned**, never deleted: „who took this ticket off these hours, and when" is exactly what the entry’s history is kept for, and the `reference.detached` revision names the row by id. Detaching one that is already detached is the same **404** as one that never existed. Like attaching, it answers to no freeze and owes no reason. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `referenceId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The ticket was detached. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/time-entries/{id}/revisions Read a time entry's history Who changed this entry, when, and what stood there before — one row per write, oldest first (ADR-2026-08-18-09LXJ). The snapshot on each revision is the state the action **replaced**; for `created` it is the initial values, and the *current* state is always the live entry. Ordered oldest first, and `position` — not `recordedAt` — is that order: `recordedAt` is the writing transaction’s clock, so a transaction that begins first and writes second carries the earlier timestamp (ADR-2026-08-18-22UND). Readable by the person the hours belong to and by owners and admins — deliberately **narrower** than the entry itself, which a colleague can read through project visibility: a history can show more than the entry does, an earlier note above all. The history outlives the entry, so a deleted one still answers here. **404** covers everything else without distinguishing: a history that is not yours, an entry older than the log, and an id that names nothing. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The entry's revisions, oldest first — `position` counts from 0 in that order.`{ data: TimeEntryRevision[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/time-entries/{id}/split Split a finished entry into consecutive parts Splits one of the caller's own finished entries into at least two consecutive parts, each on its own task, in one transaction. The parts must fill the entry exactly, or **422**. The first part keeps the entry's id, note and ticket references. A later part on the entry's own task copies the note and the references; a later part on another task starts empty. Hints linked to the entry follow the part they overlap most. **403** for a colleague's entry. **422** for a running entry or one across midnight. **409** for an entry any invoice has ever claimed (for owner and admin too), and when the entry changed in the meantime. A part on another task is a booking: an archived or unassigned task is refused as on `POST /time-entries`, and a closed day as on every write, with `editReason` for owner and admin. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `parts` | `object[]` | yes | The parts in clock order. They must fill the entry exactly: the first starts where it starts, each next one where the one before ends, and the last ends where it ends. | | `parts.taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `parts.startedAt` | `string (date-time)` | yes | Where this part starts. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `parts.endedAt` | `string (date-time)` | yes | Where this part ends. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `editReason` | `string` | no | Why hours are being split inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SplitTimeEntryResult` | application/json | The pieces, in clock order. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/time-entries/{id}/stop Stop a running timer - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `endedAt` | `string (date-time)` | no | When to stop. Defaults to now. Stopping inside the second the timer started in leaves an entry of no length, which is an ordinary row. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeEntry` | application/json | The stopped entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/time-entries/delete Delete several time entries Deletes every named entry, and like `DELETE /time-entries/{id}` there is no restore — only the `deleted` revision each one leaves behind, and the day it is missing from. Per entry, reported in `results`: `done` deleted it, `failed` carries the problem document the single-entry `DELETE` would have answered with. An entry a **live claim** holds is refused for everybody, owner and admin included, and an entry in a **closed** period needs `editReason` from a caller who may write through — 422 with `reasonRequired` for that entry alone. One refusal never stops the run. A repeated id is deleted once. `unchanged` is never answered here. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | | | `editReason` | `string` | no | Why hours that are frozen are being changed anyway — a live claim, a closed tracking period, or a day further back than this organization records time for. Applies to every entry of the batch that needs one. An entry that needs it and does not get it is **failed** with a 422 problem carrying `reasonRequired: true`, and the rest of the batch still runs. Ignored for an entry nothing is holding. It is kept with the revision each write appends, readable in that entry’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BatchTimeEntryResult` | application/json | What each named entry did. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/time-entries/deleted Deleted entries in a window of days The entries somebody deleted out of `[from, until)`, oldest first — read off the audit log’s `deleted` revisions (ADR-2026-08-18-09LXJ’s log, ADR-2026-08-26-20V59). The day and the week draw these as dimmed rows where hours are missing, which is the question this answers: **which day is short**. The window is matched against the snapshot’s own `startedAt` — the day the hours were booked on, never when somebody deleted them, which can be months later. So an entry moved from Monday to Tuesday and then deleted appears on Tuesday. **A merged-away entry is not here.** A merge deletes the entries it absorbs and records the survivor on each, precisely so a bare deletion does not read as „these hours vanished" when they moved — so those are filtered out rather than reported as missing. Defaults to the caller; naming a *specific other person’s* `personId` requires the `owner` or `admin` role, the same gate the entries list uses. Reading somebody else’s deletions is deliberately **not** widened by project visibility, and not by having been the person who deleted them. Nothing here can be changed: the entry is gone, its id names no row, and re-entering the hours is an ordinary `POST /time-entries` with every refusal intact. An empty window answers `200` with an empty list. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | | | `until` | `string (date-time)` | yes | | | `personId` | `string (uuid)` | no | Whose deleted entries. Defaults to the caller; naming a *specific other person* requires the `owner` or `admin` role. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The deleted entries, oldest first by the snapshot’s own `startedAt`.`{ data: DeletedTimeEntry[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/time-entries/export Download the filtered entries list as CSV The **whole filtered window**, not a page of it — the same filters `GET /time-entries` accepts (minus `orderBy`, `limit`, `cursor`, `running` and `harvestExport`, which the screen this mirrors does not filter on either), turned into a file rather than a screen. Applies no filter logic of its own: the file can never disagree with the list it was pressed from about which entries qualify. Above 10000 entries the request is refused with 422 rather than silently truncated — narrow the period or the filters. `Datum`, `Von`, `Bis`, `Dauer (h)`, `Person`, `Kunde`, `Projekt`, `Task`, `Abrechenbar`, `Notiz`, and — only once the filter names exactly one person, the same gate the list’s own footer uses for money — `Satz` and `Betrag` (invariant 19: no organization-wide amount, on screen or in a file). Duration is decimal hours, chosen over `H:MM` for a file that exists to be summed and pivoted; an individual entry keeps its own second precision in `Von`/`Bis`. `Satz` and `Betrag` read `gemischt` or `ohne Satz` where the entry itself does — never a hyphen, which a later import would read as zero. `Tickets` is always the **last** column, so the two money columns keep their positions: the keys of the entry’s live ticket references, in attach order, joined by `; ` — empty for an entry carrying none. The column is left out while the organization has tickets switched off. Semicolon-delimited, UTF-8 with a byte-order mark — Excel’s German-locale default, not RFC 4180’s comma. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Filter by person. Defaults to everybody’s. Naming a specific *other* person requires the owner or admin role. | | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `taskId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `budgetId` | `string (uuid)` | no | Filter to the projects a budget covers. Narrows projects only, exactly as the list's own `budgetId` does. | | `from` | `string (date-time)` | no | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | no | Exclusive upper bound on startedAt. | | `isBillable` | `string` | no | | | `externalKey` | `string` | no | Only entries carrying a live reference to this ticket key, read in the spelling its system uses — `eng-1` finds `ENG-1`. Combine with `externalProvider` to name one ticket, since a key alone can exist in two systems. | | `externalProvider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | no | Only entries carrying a live reference in this ticket system. | | `hasExternalReference` | `string` | no | true = only entries with at least one live reference, false = only entries with none. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string` | text/csv | The filtered entries, one row per entry, oldest filter match first. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/time-entries/insert Insert a finished entry, cutting the entries it overlaps Books a finished entry for the caller and cuts the caller's own entries it overlaps, in one transaction. For every overlapping entry with `keep: false`: one that contains the window is **split** — it ends at `startedAt`, and a new entry on its task, notes and ticket references runs from `endedAt` to its old end (running again if it was running); one that overlaps the window's start is **shortened**, one that overlaps its end is **moved** to start at `endedAt`, and one entirely inside is **deleted**. An entry with `keep: true` is left alone and overlaps the new one. The piece that keeps an entry's id is the one whose start does not move, so a split running timer goes on under a new id. `affected` must name exactly the overlapping entries — any other set answers **409** with `overlapping`. **409** too for an entry to be cut that any invoice has ever claimed (for owner and admin too), and for one to be deleted that was exported to Harvest. **422** for a window across midnight, more than 20 overlapping entries, or a cut of the running timer at an instant still in the future. The new entry is a booking: an archived or unassigned task is refused as on `POST /time-entries`, and a closed day as on every write, with `editReason` for owner and admin. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | yes | When the work started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time)` | yes | When it ended — required: the window is what gets cut out. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `notes` | `string \| null` | no | | | `affected` | `object[]` | yes | Every own entry overlapping `[startedAt, endedAt)`, each with the caller’s choice: `keep: true` leaves it overlapping, `keep: false` cuts it. Must name exactly the overlapping set — any other set answers **409**, and the caller reads the day again. | | `affected.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `affected.keep` | `boolean` | yes | | | `editReason` | `string` | no | Why hours are being booked, cut or deleted inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `TimeEntry` | application/json | The inserted entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/time-entries/insert-break Record a break, cutting the entries it overlaps Stores a break for the caller and cuts every one of the caller's own entries it overlaps, in one transaction — the cut `POST /time-entries/insert` makes, with a break instead of a new entry and no entry kept. A break is not tracked time; it answers the stretch the cut leaves. `affected` must name exactly the overlapping entries — any other set answers **409** with `overlapping`. **409** too for an entry to be cut that any invoice has ever claimed (for owner and admin too), and for one to be deleted that was exported to Harvest. **422** for a window across midnight, more than 20 overlapping entries, or a cut of the running timer at an instant still in the future. A closed day refuses the cut as on every write, with `editReason` for owner and admin. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `startedAt` | `string (date-time)` | yes | When the break started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time)` | yes | When it ended. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `affected` | `string (uuid)[]` | yes | Every own entry overlapping `[startedAt, endedAt)`; each is cut. Must name exactly the overlapping set — any other set answers **409**, and the caller reads the day again. | | `editReason` | `string` | no | Why entries are being cut or deleted inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `BreakPeriod` | application/json | The recorded break. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### GET /api/v1/time-entries/last-stopped Get the caller’s most recently stopped time entry The current user's own most recently *stopped* entry in the active organization, or `null` — what `timjami resume` restarts, on the task it names. Ordered by `endedAt`, not `startedAt`: the two agree for anyone who only ever presses start and stop in order, and disagree the moment a backdated entry is booked after something newer already stopped, which is exactly the case this endpoint exists to get right rather than approximate (ADR-2026-09-12-06L4N). A currently running entry is never returned here — see GET /time-entries/running. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The most recently stopped entry, or null.`{ data: TimeEntryWithContext \| unknown \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/time-entries/merge Merge time entries into one Folds a chain of entries that describe one stretch of work — the stopped-and-immediately-restarted timer — into a single entry. The **earliest** entry survives and its end reaches forward to the last one's; the others are deleted, and any hint linked to them moves to the survivor. `startedAt` never moves, so a merge is always an extension. The chain must be **one person, one task and one calendar day** (the organization's timezone), and the seams between the entries are absorbed into the merged entry — so a seam wider than 15 minutes answers **422** rather than recording time nobody worked. A running entry may only be the last of the chain, and the merged entry then goes on running. **409** where something outside Timjami already knows about these hours: an entry any invoice has ever claimed — including a withdrawn one, and for owner and admin too — or a Harvest export on anything but the first entry, because absorbing an exported entry would leave Harvest billing its hours twice. Without `notes`, the entries' distinct notes are joined with ` · ` in clock order. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | | | `notes` | `string \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeEntry` | application/json | The merged entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/time-entries/policy What a booking or a move would be allowed to do Resolves the freezes for a write that has not happened: booking on `taskId` at `startedAt`, or — with `entryId` — moving that entry there. `permission` is `free`, `reason` (allowed, and the save must carry `editReason`) or `locked`, and `blockers` says why in a shape a screen can render. It informs and never grants: the write path decides again, so a period closed between this answer and the save still refuses. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | The task the hours would be booked on. | | `startedAt` | `string (date-time)` | yes | When they would start. The **day** this falls in is what decides. | | `entryId` | `string (uuid)` | no | An existing entry this write would move, if any — its own day and project are then asked as well, because moving hours *out of* a closed period is refused like moving them in. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | What this write may do.`{ data: WritePolicy }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/time-entries/progress Tracked time against its target, and what it is worth The sidebar week meter’s figures, for any period: tracked seconds, the target (a working time model, or hoursPerDay × Mo–Fr, less absences while the organization has them on), and the billable amount — both for the whole `[from, until)` and again restricted to "through today" in the organization’s own timezone. Running entries are excluded by default, the same rule `/time-entries/summary` follows — add the live timer yourself from `GET /time-entries/running`, or pass `includeRunning` to have this fold the caller’s own running entry in instead, valued once as of now (see `runningIncluded` on the result). Defaults to the caller; naming a *specific other* person’s `personId` requires the owner or admin role, the same gate `/time-entries/summary` uses. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | yes | Exclusive upper bound on startedAt. | | `personId` | `string (uuid)` | no | Defaults to the caller. Naming a specific *other* person requires the owner or admin role. | | `includeRunning` | `string` | no | Fold the caller's own running entry into trackedSeconds/billableSeconds and the money figures, valued as of now — see runningIncluded on the result. A caller that folds its own live timer in already (the web sidebar meter) leaves this at its default. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeProgress` | application/json | The range’s progress. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/time-entries/recent-notes Suggest notes for starting a timer The caller's own most-used notes, most suggestible first — what the timer-start field offers as one-click text (ADR-2026-09-04-08MT6). Ranked by frecency over the trailing window: how often a note was used and how recently, so a habit from weeks ago fades out rather than permanently outranking something typed every day this week. Never scored across colleagues — this is what one person tends to write, the same scope `/tasks/recent` uses for what they tend to book. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `days` | `integer` | no | | | `limit` | `integer` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The suggested notes, most suggestible first.`{ data: string[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/time-entries/retask Move several time entries onto one task Moves every named entry onto `taskId` — customer and project follow the task, and what the hours are worth is resolved again from the target project’s rate rules, because an entry stores no money. Per entry this is `PATCH /time-entries/{id}` with `taskId` and nothing else, refusal for refusal. What is asked **once**, about the target rather than the selection, refuses the whole request: **403** where the caller may not book on that task, **409** where the task, its project or its customer is archived — no override for anybody. Everything else is per entry and reported in `results`: `done` moved it, `unchanged` means it already sat on that task (nothing written, no revision), and `failed` carries the problem document the single-entry `PATCH` would have answered with — the same `claimed`, `periodClosed`, `backdatingLimit` and `reasonRequired` members a client already renders. One refusal never stops the run. A repeated id is written once. Order is the run order. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | | | `taskId` | `string (uuid)` | yes | The task every named entry moves onto. | | `editReason` | `string` | no | Why hours that are frozen are being changed anyway — a live claim, a closed tracking period, or a day further back than this organization records time for. Applies to every entry of the batch that needs one. An entry that needs it and does not get it is **failed** with a 422 problem carrying `reasonRequired: true`, and the rest of the batch still runs. Ignored for an entry nothing is holding. It is kept with the revision each write appends, readable in that entry’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BatchTimeEntryResult` | application/json | What each named entry did. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/time-entries/revisions What changed in one month One month of the time entry audit log — every `created`, `updated` and `deleted` write recorded in it (ADR-2026-08-18-09LXJ’s log, ADR-2026-08-26-20V59). „Protokoll" reads this beside `GET /absences/revisions` and shows the two as one chronology. The month bounds **when a change was recorded**, resolved in the organization’s own timezone — never the day the hours sit on, which can be months earlier. Inside the month the answer is one **page**: newest first, `limit` rows, `nextCursor` where more follow — pass it back unchanged together with the same `month` and `userId`. An organization’s month of writes is unbounded, which is what the page is for. **Merging this with `GET /absences/revisions` is only honest down to the older of the two frontiers.** Each log pages on its own, so after one request each you hold the entry log back to one instant and the absence log back to another. Show the merged chronology down to the *newer* of those two boundaries and keep the rest until the other log has been paged that far — below it, one log is complete and the other is not, and a chronology missing every change of one kind looks like a month in which none happened. **The order is the answer’s own.** Rows arrive grouped by entry, oldest write first inside each group — the order the writes happened. That is what makes a change derivable: a revision holds the state its write *replaced*, so what it changed is the step to the next snapshot of the same entry. Sort by `recordedAt` for display and never for pairing. `heads` closes that derivation at the edge of the **page**: for each entry, the state its newest write on this page produced — the next revision’s snapshot where the entry was changed again later (which, for an entry seen on the page before, is that page’s oldest row for it), the live entry where it was not, and **nothing at all** where the entry was deleted, which needs no such state. So each page derives on its own and a client merges the results; splicing one entry’s chain across two pages would need the write order, which does not travel. Whose changes: your own by default, and naming a *specific other person’s* `userId` requires the `owner` or `admin` role. An owner or admin who names nobody reads the whole organization’s; a member who names nobody reads their own. Deliberately not widened by project visibility, and not by having been the person who made the change — a member may delete a colleague’s entry and may not read that entry’s history. An empty month answers `200` with empty lists. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `personId` | `string (uuid)` | no | Whose changes. A member may only name themselves and reading without it answers with their own; owners and admins may name anybody, and reading without it answers with the whole organization’s (`time.readOthers`). | | `limit` | `integer` | no | | | `cursor` | `string` | no | Keyset cursor **within the month**: pass the previous page's `nextCursor` back unchanged, together with the same `month` and `personId`. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | One page of the month’s revisions, grouped by entry in write order, plus one head per entry that has one.`{ data: TimeEntryLogEntry[], heads: TimeEntryLogHead[], nextCursor: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/time-entries/running Get the running time entry The current user's running entry in the active organization, or `null`. A timer running in a *different* organization is reported as `null` here but still blocks starting a new one — see POST /time-entries/start. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The running entry, or null.`{ data: TimeEntryWithContext \| unknown \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/time-entries/start Start a timer Resolves the billable rate from the project/task/member configuration and **freezes it** on the entry. Later rate changes do not affect entries that already exist. A user may have at most one running timer across all organizations. If one is already running, this returns 409 with `runningEntryId`; pass `stopRunning: true` to stop it and start the new one. An archived task — or one whose project or customer is archived — answers **409** with `archived` naming the level. Archiving takes something out of the way for new time; the hours already on it keep their place. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `notes` | `string \| null` | no | | | `startedAt` | `string (date-time)` | no | When the timer started. Defaults to now — useful for "I forgot to start the timer 10 minutes ago". Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `stopRunning` | `boolean` | no | | | `editReason` | `string` | no | Why hours are being booked into a **closed** tracking period anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the day this entry starts in is at or below its project’s `closedThroughDay` and the request gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other booking. It is kept with the revision this write appends, readable in the entry’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `TimeEntry` | application/json | The started entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/time-entries/summary Total tracked seconds in a window Used for the "today" figure in the UI. Running entries are excluded because their duration is not yet fixed — the client adds the live timer itself. Defaults to the caller; naming a *specific other person's* `personId` requires the `owner` or `admin` role. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | | | `until` | `string (date-time)` | yes | | | `personId` | `string (uuid)` | no | Defaults to the caller. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The total.`{ totalSeconds: integer }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Invoices Billing a period of tracked time. An invoice groups entries that already carry their rate; it never resolves one, and an hour it claims cannot be billed twice. ### GET /api/v1/invoices List invoices Newest first. `orderBy: 'createdAt'` (the default) paginates with `createdBefore` set to the previous page's `nextCursor`. `orderBy: 'updatedAt'` orders by when an invoice was last written instead — a status change or an Easybill export moves a row without changing its `createdAt` — and paginates with `cursor` instead: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes; like the entries list this order is under, this is a best-effort sort, not a linearizable change feed. Lines are on the individual invoice, not here. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `status` | `"draft" \| "transferred" \| "withdrawn"` | no | 'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched. | | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `ids` | `string` | no | Comma-separated invoice ids: only these. The drafts one request created are listed together this way (ADR-2026-10-07-119RC). | | `limit` | `integer` | no | | | `createdBefore` | `string (date-time)` | no | Keyset cursor from nextCursor, for the default `orderBy: 'createdAt'`. | | `orderBy` | `"createdAt" \| "updatedAt"` | no | 'createdAt' (the default) is the ordinary list, newest invoice first, paginated with `createdBefore`. 'updatedAt' orders by when an invoice was last written instead — a status change (`transfer()`, `withdraw()`) or an Easybill export moves a row without changing its `createdAt` — and is paginated with `cursor` instead: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes. | | `cursor` | `string` | no | Keyset cursor for `orderBy: 'updatedAt'`: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`. Not accepted with the default order, which pages on `createdBefore` instead. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceList` | application/json | The invoices. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/invoices Create a draft invoice Groups the billable time of one customer in a period into lines and **claims** the seconds behind them: an interval of an entry is on at most one live invoice, enforced by a gist exclusion constraint rather than by a check. A 409 means somebody else billed some of these minutes in between. Each claim also snapshots what it billed — the window, the task, the person and the rate — so the document keeps its meaning if the entry is later corrected. The result is a draft — editable and deletable, and the place an invoice is checked. It leaves Timjami either through `POST /invoices/{id}/export`, which transfers and exports it in one request, or through `POST /invoices/{id}/transfer` for an organization billing elsewhere. Nothing is ever issued from Timjami itself. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectIds` | `string (uuid)[]` | no | | | `budgetIds` | `string (uuid)[]` | no | The budgets to bill, or empty for every pot. Exactly the pots named, never their overflow chain. Seconds in other pots are reported as `outside-selection` rather than dropped. | | `taskIds` | `string (uuid)[]` | no | The tasks to bill, or empty for every task of the selected projects. Hours on other tasks are reported as `outside-selection`. | | `from` | `string (date-time) \| null` | yes | Inclusive lower bound on the entry start, or null for no lower bound. | | `until` | `string (date-time)` | yes | Exclusive upper bound on the entry start. | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | | `notes` | `string \| null` | no | Free text for the invoice. Never filled from a time entry’s notes, which may be internal. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `InvoiceWithLines` | application/json | The created draft. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/invoices/{id} Get an invoice with its lines - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The invoice. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/invoices/{id} Change a draft’s notes Drafts only. A transferred invoice is out of Timjami’s hands; correcting it means withdrawing it and building a new one (ADR 0037). - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `notes` | `string \| null` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The updated draft. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/invoices/{id} Delete a draft or a withdrawn invoice A transferred invoice must be withdrawn first. Deleting releases the time entries for good — including for deletion, which a live claim prevents. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The invoice was deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/invoices/{id}/document-refresh Read this invoice’s document back from the invoicing tool, now Asks the invoicing tool about the document this invoice was exported to, instead of waiting for the hourly refresh, and answers the invoice as it stands afterwards. Meant to be called when somebody opens the invoice, so the `document` object is current for the person looking at it. The tool asked is the one the invoice was exported to (`export.provider`): Easybill or sevdesk (ADR-2026-10-26-184PV). **It never fails for the tool’s sake.** An invoice that was never exported, or was exported to a connection that has since been replaced, is answered unchanged; so is one whose reading is less than a minute old, and so is a refusal by the tool — Easybill’s rate limit is ten requests a minute on the smaller tier, shared with exports. Read `document.fetchedAt` to see how current the answer is. Only the tool’s own 404 changes anything on such a path: it marks the document deleted. A sevdesk cancellation is a second document and reaches the reading with the hourly refresh, not here. This writes nothing of Timjami’s own: the mirror is shown, never obeyed, which is why reading an invoice is all the authority it takes. See ADR 2026-09-18-21LNB. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The invoice, with whatever the tool now says. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/invoices/{id}/export Export an invoice to the connected invoicing tool **Exporting is what transfers an invoice out of Timjami**, so this takes a draft and leaves it `transferred` — there is no separate "issue it first" step. An invoice that is already `transferred` (the escape hatch was used, or a previous export failed) is exported without changing its status. The tool is the organization’s connected one (`GET /invoicing-provider`); the response names it in `export.provider`. 404 if none is connected. 409 if the invoice was already exported — this never creates a second document for the same invoice — or if it has been withdrawn. A 409 about a customer that could not be resolved carries `customerId` (the Timjami customer) and `provider`, which says whose mapping resolves it. A definite failure leaves a draft a draft, so nothing looks transferred that never left. An ambiguous outcome (a timeout, an unreadable reply) keeps the invoice `transferred` and claimed, `export.unclear` is `true`, and `PUT /invoices/{id}/export/document` or `DELETE /invoices/{id}/export` resolves it (ADR-2026-10-20-08BYN). `exportNotice` names a difference the export recorded anyway: sevdesk’s draft has a net total or a position count other than the invoice’s. The document exists, so the export is recorded and a retry cannot create a second one; the draft is to be checked in sevdesk before it is finalized. `null` when nothing differs, and always `null` for Easybill. The document is created as a **draft** in the tool, deliberately: that is where the VAT treatment and the customer address get checked before a Rechnungsnummer is assigned. So this records that the tool received the invoice, not that anybody sent it — and Timjami never learns the number. See ADR 0061, ADR 0068, ADR 2026-08-13-125XN and ADR-2026-10-20-1522X. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ExportedInvoice` | application/json | The invoice, with the exported document recorded. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/invoices/{id}/export Release an unclear export that created nothing For an invoice whose export is **unclear** (see `PUT /invoices/{id}/export/document`), after checking the tool and finding no document: releases the claim, so the invoice can be exported again, and puts it back to `draft` when the export itself transferred it. An invoice transferred by hand stays `transferred`. 409 for any other invoice. A claim the export marked unclear resolves at once; one without that marker only after 15 minutes, since it may still belong to a running export. Releasing while the document exists makes the next export a duplicate — check first. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The invoice, with the claim released. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/invoices/{id}/export/document Record the document found for an unclear export For an invoice whose export is **unclear**: its `export` is present with `documentId` `null`, because the tool answered ambiguously (a timeout, or a reply that could not be read) and may have created the document anyway. After finding it there, this records its id as the export’s own — the invoice then reads like any other export. 409 for any other invoice. A claim the export marked unclear (`export.unclear`) resolves at once; one without that marker only after 15 minutes, since it may still belong to a running export. The id is not verified against the tool. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `documentId` | `string` | yes | The document's id in the invoicing tool the export went to. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The invoice, with the document recorded. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/invoices/{id}/recorded-document Record what is known about the document an invoice billed by hand became Replaces all six recorded fields — number, Rechnungsdatum, due day, link, sent day and paid day; `null` clears one. For an invoice written in a program Timjami is not connected to: the number arrives after the invoice was marked, the payment weeks later. **Shown, never obeyed.** Recording a payment completes nothing and frees nothing; the invoice’s status, its claims and every figure derived from them stay as they are. The `state` it answers with is read off these days against today (ADR 2026-10-08-13YT8). 409 unless the invoice is `transferred` and was never exported: an exported invoice’s document is the tool’s and reads back as `document`, and a withdrawn one no longer changes. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `number` | `string \| null` | yes | | | `documentDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `dueDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `url` | `string \| null` | yes | | | `sentDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `paidDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The invoice, with the document recorded. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/invoices/{id}/transfer Mark a draft as transferred to the invoicing tool Records that this invoice has left Timjami: it freezes the invoice and stamps `transferredAt`. Timjami assigns no invoice number and never issues an invoice itself — the invoicing tool does both, and what happens there is deliberately invisible here. See ADR 0068 and ADR 2026-08-13-125XN. For an organization that has connected an invoicing tool, `POST /invoices/{id}/export` does this itself, in the same request that creates the document — the export *is* the transfer. This endpoint is what an organization billing elsewhere uses instead; nothing here can verify the claim that the invoice is being billed somewhere. Sets `transferredByHand` when no invoicing tool is connected: the transfer is then the whole settlement. With a tool connected it records the transfer but not that, since the tool's export is what settles it. An optional `recordedDocument` records, in the same step, what the person who wrote the invoice knows about it — number, dates, a link. It is shown, never obeyed: nothing about the invoice’s status, claims or money reads it (ADR 2026-10-08-13YT8). - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `recordedDocument` | `RecordedDocumentInput` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The transferred invoice. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/invoices/{id}/withdraw Withdraw a transferred invoice Takes the invoice back in Timjami. It keeps its lines, its total and its `transferredAt` — it is the record of what was billed — while its hours become billable again, so they can go on a corrected invoice. **This is not a Storno.** A document already created in Easybill is untouched, and Timjami cannot reach it: cancelling or crediting it there is a separate, manual step. See ADR 0061 and ADR 2026-08-13-125XN. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceWithLines` | application/json | The withdrawn invoice. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/invoices/allowance-divergences Billed allowance months whose agreement has since changed What an invoice froze for a monthly time allowance („Pauschale"), beside what the agreement behind it says today. The sibling of `GET /invoices/divergences`, for the unit that is a **month** rather than a stretch of an entry. It exists because the two sides are read differently: an invoice freezes the month’s seconds, while budget consumption and Kennzahlen revenue derive the month from the agreement and its adjustments **live**. So correcting a Pauschale, entering a delta for a month already billed, or superseding an agreement from an earlier month leaves the document and every figure beside it disagreeing — and until this endpoint existed, nothing said so. It **reports and never repairs**, exactly like its sibling. The answer to a row is to withdraw and rebuild the invoice — withdrawing releases the month, so rebuilding it is a real option — or to accept it. `kinds` names what disagrees: `seconds` for a month whose value moved, `outside-span` for one the agreement no longer covers at all. The two never appear together, since outside the span there is no figure left to disagree with. **Rate and currency are not compared** — that needs a month’s current rate per pot, which is a separate question. Answered **whole rather than paged**, unlike its sibling: the live set is bounded by agreements × months, so there is no cursor. `scanned` is how many live allowance months were compared and `truncated` says whether a ceiling was reached — `false` is the only thing that means „every billed month was compared", and it is the normal answer. `invoiceId` asks the same comparison of **one invoice**, which is what a notice band on that invoice reads, and answers **404** for an invoice that does not exist so a wrong id cannot read as „nothing has drifted". Session-only: a leaked credential must not be able to learn what it is getting away with. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `invoiceId` | `string (uuid)` | no | Compare only the allowance months of this invoice. **404** for an invoice that does not exist, so a wrong id cannot read as „nothing has drifted". | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Every live allowance month that disagrees with its agreement. Empty is the normal answer.`{ data: AllowanceDivergence[], scanned: integer, truncated: boolean }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/invoices/divergences Live claims that no longer match the time underneath them What an invoice billed, beside what its entries now say. A time entry is also the working-time record, so it has to stay correctable after a document went out — the `owner` and `admin` roles may move times an invoice is billing, may change a task’s billability, and may correct a rate-rule version that already priced invoiced hours. This is the counterpart that makes each of those a reviewed state rather than a silent drift. It **reports and never repairs**. What the customer was billed is whatever the frozen line says; resolving a divergence means withdrawing and rebuilding the invoice, or accepting it. `kinds` names what disagrees: the window, the task, the person, the **rate** — the entry would be valued differently today — or `over-billed`, a claim reaching past the entry’s current duration because it was shortened after being billed. `invoiceId` asks the same comparison of **one invoice** and is what a notice band on that invoice reads: the organization-wide set grows without bound, but one document’s claims are bounded by its own lines, so a narrowed request is normally a complete answer rather than the newest page of everything. It narrows the scan, not the rows — an invoice whose claims are older than one page is compared, not skipped — and answers **404** for an invoice that does not exist, so a wrong id cannot read as „nothing has drifted". Newest first and **bounded per request**, because a live claim is never cleaned up: the set grows with the whole invoicing history while the divergences in it stay a handful. `nextCursor` names the last claim *examined* — not the last one reported, since a page may find nothing and still have older claims behind it — and `null` is the only thing that means „every live claim was compared". An empty `data` beside a non-null `nextCursor` means „nothing found in what was looked at", never „nothing has drifted"; pass the cursor back, with the same `limit`, until it comes back `null` to check them all. `scanned` is how many live claims this request compared — a page that reports nothing still examined a page of them — and `totalClaims` how many a whole sweep would examine, counted once on the first request (no `cursor`) and `null` on every continuation. Both are for a progress display and nothing else: claims are written and released while a sweep runs, so the end of one is `nextCursor: null`, never a counter reaching a total. Session-only: a leaked credential must not be able to learn what it is getting away with. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `limit` | `integer` | no | How many live claims to compare in this request. Defaults to the full per-request bound; a smaller page is what a client sweeping the whole history uses so its progress display moves. | | `cursor` | `string` | no | Keyset cursor: pass the previous page's `nextCursor` back unchanged. It names the entry's instant, the entry, **and** the claim, so two claims on one entry are not skipped at a page boundary. | | `invoiceId` | `string (uuid)` | no | Compare only the claims of this invoice. The organization-wide scan grows with the whole invoicing history and is therefore walked page by page; one invoice’s claims are bounded by its own lines, so a narrowed request is normally the complete answer — `nextCursor` still says whether it was. **404** when no such invoice exists, so a wrong id cannot read as „nothing has drifted". | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Every live claim that disagrees with its entry. Empty is the normal answer.`{ data: Divergence[], nextCursor: string \| null, scanned: integer, totalClaims: integer \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/invoices/easybill-outstanding What Easybill still has outstanding One organization-wide figure over the Easybill document mirror: the **gross** amounts Easybill has not recorded as paid, with the overdue part of them as a subset (ADR-2026-09-20-18BC8). Read with `invoice.read`, like every other invoice route. Without `customerId` that is the whole organization; with it, every figure below covers that customer's invoices alone. A document counts as open in `finalized`, `sent`, `partiallyPaid` and `overdue` — a `draft` is not yet an invoice anybody owes, and `paid`, `cancelled` and `deleted` are finished. `archived` is not a state and excludes nothing: an archived invoice is still paid or not. Each open document contributes `amountMinor - paidAmountMinor`, so a part payment leaves the rest standing, and the states come from the same derivation the invoice resource's `document.state` does, against today in the organization's zone. `overdueMinor` is read off the **due date**, not off the `overdue` state: that state is one label chosen by precedence and `partiallyPaid` outranks it, so a document half paid and two months late is labelled „Teilweise bezahlt" and is still late money. A label has to pick one word; this total is splitting money. These are **Easybill’s figures, gross**, including the VAT Timjami never models, and they are shown, never obeyed (ADR-2026-09-18-21LNB): nothing in Timjami reads this number, and no comparison with the invoices’ own `subtotalMinor` is drawn. There is no rounding — integer cents are summed — and no period: the question is "what is open". An open document whose amount cannot be stated in the organization’s currency — none from Easybill yet, or a currency changed by hand over there — is counted in `openCount` and in `withheldCount`, and left out of the amounts, so a screen can say what the total does not cover rather than quietly understating it. `oldestFetchedAt` is the **least** `fetchedAt` among the documents counted: a total is only as current as its stalest member. An invoice withdrawn in Timjami still counts while its document is open in Easybill — withdrawing does not reach Easybill (ADR-0061), and "laut Easybill" is the whole claim this endpoint makes. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | Narrows every figure in the answer to the invoices of this customer. Omitted, the total covers the whole organization. The same narrowing `GET /invoices` takes, so a filtered table and the figure above it agree. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillOutstanding` | application/json | The outstanding total. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/invoices/externally-billed Hours another system has billed, grouped by its documents What was billed outside Timjami, read back out of the `external` claims the Harvest import writes — today the only source of one. Two rollups of the same seconds in one response: `documents`, one row per document the source named, and `customers`, the same hours by customer and project. Both are sums of one set of already-rounded (document, project, rate) groups, so the two readings agree to the cent. **A row is not the invoice.** Harvest states an id and a number on a time entry and nothing else — no total, no issue date, no state — so a row is "the hours carrying that number", and `amountMinor` is reconstructed from rate times seconds. It will not match the document: the other tool rounds its own way, and Pauschalen, Auslagen, discounts and tax are not hours and are absent here entirely. **`documentRef: null` is the group to look at.** The source called those hours billed while naming no document, which is how work nobody would ever be paid for was got out of Harvest's uninvoiced report — marking it invoiced, which is simply false. `total` restates that group as `withoutDocument` so a client can say so without knowing which row means it. Seconds come in three buckets that add up to `seconds`: priced, `unratedSeconds` (billed with no rate stated, which for an `external` claim is a designed state rather than an error) and `otherCurrencySeconds` (billed in a currency the organization has left). Only the first is inside `amountMinor`. No period and no cursor — the question is what another system has billed, which has no lower bound — and archived customers and projects stay listed: archiving never hides historical money or names, and an abandoned project is the ordinary case here. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ExternallyBilledSummary` | application/json | Everything another system has billed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/invoices/externally-billed/document Repair the invoice reference on hours another system billed Writes a Harvest invoice reference onto the live `external` claims a row of „Extern abgerechnet" names — the repair for a reference **Harvest itself lost**. When an invoice was split or two were merged, Harvest kept the association on some of the time entries on it and dropped it on the others; the document exists, and the entries that still carry it are what say which one it is. The invoice travels as its **two parts** and never as a finished reference: the server composes `harvest:invoice::`, so a typo cannot turn one invoice into two documents differing by a colon. `invoice: null` clears the reference instead, which returns those hours to the „Ohne Rechnung" group — the undo of a wrong repair. **It moves no money and settles nothing.** The claim keeps its rate, its currency, its billed window and its claimed seconds; only the document they are attributed to changes. Which is why it is one update rather than a release and a re-claim: the seconds are never briefly free, so nobody can bill them in between. Owner and admin only (`time.editBilled`), and session-only with it: a leaked token must not be able to restate which document billed what. **422** when the selection holds no live external claim any more — a screen left open, or a project that was already repaired; it never answers 200 for a write that touched nothing. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `selector` | `ExternalClaimSelector` | yes | | | `invoice` | `object \| null` | yes | The invoice to attribute these hours to, or `null` to clear the reference — the undo of a wrong repair. | | `invoice.harvestInvoiceId` | `string` | yes | Harvest’s own invoice id — the segment that identifies the document. Opaque; nothing does arithmetic on it. | | `invoice.harvestInvoiceNumber` | `string \| null` | yes | The number a person recognises, or `null` for an invoice Harvest gave none. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ExternalClaimRepair` | application/json | What the repair wrote. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/invoices/externally-billed/release Take back an external settlement, freeing its hours again Releases the live `external` claims a row of „Extern abgerechnet" names: `released_at` is set, and nothing else changes. The answer for hours another system called billed that nobody was ever paid for — in Harvest, marking work invoiced was the only way to get it out of the uninvoiced report. The seconds are free the instant this commits: they reappear in `GET /invoices/unbilled` and are offered to the invoice builder and to a write-off. What happens to them next is a second decision, made on those screens. **One thing to know before pressing it:** writing them off needs a rate the project’s dated rules resolve, because a write-off’s amount is the decision it records — Harvest’s own rate lives on the claim being released and does not carry over. **Released, never deleted.** The claim row stays, so what the other system claimed remains readable, and the entry stays undeletable and unmergeable. There is no endpoint that deletes a claim. Owner and admin only (`time.editBilled`), session-only with it. **422** when the selection holds no live external claim any more, which is also how two simultaneous releases resolve to one winner. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `selector` | `ExternalClaimSelector` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ExternalClaimRepair` | application/json | What was released. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/invoices/preview What an invoice for this selection would contain Writes nothing. Returns the lines that `POST /invoices` would store, computed by the same code — plus what inside the period is *not* on them and why: a non-billable task, an hour no rule prices, or seconds another live invoice already claims. A POST rather than a GET because the selection is a document with a list of projects in it, not a handful of query parameters. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectIds` | `string (uuid)[]` | no | | | `budgetIds` | `string (uuid)[]` | no | The budgets to bill, or empty for every pot. Exactly the pots named, never their overflow chain. Seconds in other pots are reported as `outside-selection` rather than dropped. | | `taskIds` | `string (uuid)[]` | no | The tasks to bill, or empty for every task of the selected projects. Hours on other tasks are reported as `outside-selection`. | | `from` | `string (date-time) \| null` | yes | Inclusive lower bound on the entry start, or null for no lower bound. | | `until` | `string (date-time)` | yes | Exclusive upper bound on the entry start. | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceDraft` | application/json | The lines this selection would produce. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/invoices/recorded-outstanding What the invoices billed by hand still have open The hand path’s counterpart of `GET /invoices/easybill-outstanding`: one figure over the invoices written in a program Timjami is not connected to, by what their authors recorded (ADR 2026-10-08-13YT8). Without `customerId` that is the whole organization; with it, every figure covers that customer's invoices alone. An invoice counts as open while it is transferred by hand, never exported, has **something** recorded and no paid day. One with nothing recorded is not counted: it says as little about being paid as about anything else, and counting it would leave every invoice ever billed by hand standing as open. A withdrawn invoice is not counted. The amounts are the invoices’ own frozen **net** totals (`subtotalMinor`) — Timjami models no VAT, so this is not what the customer owes gross. Integer cents are summed with no further rounding, and an invoice in another currency than the organization’s is counted in `openCount` and `withheldCount` and left out of the amounts. `overdueMinor` is the part whose recorded due day has passed in the organization’s zone. Shown, never obeyed: nothing in Timjami reads this figure. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `RecordedOutstanding` | application/json | The outstanding total. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/invoices/status An invoice's status, customer, period and transfer state — no amount The same list as `GET /invoices`, narrowed to what carries no money: `id`, `status`, `customer`, `periodStart`/`periodEnd`, `transferredAt`, `withdrawnAt`, `export` (provider and time, no document) and `transferredByHand`. Never `currency`, `subtotalMinor`, the export's `documentId`, or the `document` mirror (invariant 82). `periodFrom`/`periodUntil` filter by the invoice’s own billed period touching that window — "in August" means the period overlaps August, not that `transferredAt` fell in it, since a monthly invoice for the *previous* month routinely transfers in a different month than the one it bills, and `transferredAt` is `null` for a draft. Paginated exactly like `GET /invoices`’s default order: newest `createdAt` first, `limit` (default 50, capped 200) and `createdBefore`. - Scope: `thymian:invoice-status.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `status` | `"draft" \| "transferred" \| "withdrawn"` | no | 'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched. | | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `periodFrom` | `string (date-time)` | no | Only invoices whose own billed period touches this window — the period the invoice states, not when it was transferred. Omit for no lower bound. | | `periodUntil` | `string (date-time)` | no | The other end of the period-overlap window. Required together with periodFrom. | | `limit` | `integer` | no | | | `createdBefore` | `string (date-time)` | no | Keyset cursor from nextCursor: newest invoice first, paginated by created time. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoiceStatusList` | application/json | The invoices. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/invoices/unbilled Hours that are finished, billable and on no live invoice What is still to be billed, one row per customer with its projects inside. The exact inverse of the `already-invoiced` exclusion — finished, billable **seconds** no live invoice claims, so an entry extended after it was billed owes its tail here — with **no period**: the question is "what is open", not "what happened in July". Read by every member, like the other invoice routes and unlike `reports/hours-by-user`: the view does not break down by person on purpose, so it is not the management question that gating exists for (ADR 0039). Amounts round per (project, rate) group from whole seconds, the way an invoice line does (invariant 16) — not per entry like the week view. Rates are resolved per month from the project's dated rules, so a project repriced in September produces two figures for one task rather than an average. Billable hours no rule prices travel as `unratedSeconds` and are never part of `amountMinor`. Archived customers and projects stay listed while they still owe hours; running timers are absent. `total` is everything open over every customer, summed from the rows already rounded — so a client states what is outstanding without adding the rows up itself, and cannot arrive at a figure a cent away from them. `includeCurrentMonth=false` is the one narrowing there is: it counts only entries that started before the running month, which is what somebody billing monthly is asking about — nothing tracked this month is due yet. The boundary is a calendar one in the organization's zone, so it cuts where the rate resolution's own months cut, and the answer states what it counted in `throughMonth` rather than leaving a client to derive it (ADR-2026-09-07-15TM4). Everything else is unchanged: still no lower bound, the same rounding, the same rollups. Fixed-price items („Posten") no live invoice holds are open too, from the moment they exist: each level states `fixedPriceItemCount` and `fixedPriceAmountMinor`, with the not yet completed part named as `upcomingFixedPrice…`. An item has no seconds, so these figures stand **beside** `amountMinor` and are never part of it, and an item in another currency is only counted (`otherCurrencyFixedPriceItemCount`), never summed (ADR-2026-10-18-07ZA3). `includeCurrentMonth=false` also leaves out items not completed by the end of `throughMonth`. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `includeCurrentMonth` | `string` | no | false counts only entries that started before the running month — the reading a monthly biller wants, since nothing in the current month is due yet. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `UnbilledSummary` | application/json | Everything still to be billed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/invoicing-provider The connected invoicing tool Which invoicing tool exports go to: `provider` is `null` when none is connected, and the invoice is then marked as transferred by hand. An organization has at most one (ADR-2026-09-25-14DWP). Connecting and mapping customers stay with each tool’s own routes. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InvoicingProviderConnection` | application/json | The connected tool, if any. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Harvest Exporting your own tracked time to Harvest as time entries, using a personal connection you set up once. Re-exporting an entry updates the same Harvest entry rather than creating a second one. ### GET /api/v1/harvest This person's Harvest connection `configured: false` hides the export screen. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/harvest Connect this person’s Harvest account Reads `/company` and `/users/me` with the given token. Refused for a duration-mode account — Harvest ignores `started_time`/`ended_time` there, and this exporter always sends both. A Harvest email that differs from this person’s Timjami email needs `confirmDifferentEmail: true`, turning the match into a confirmation rather than a guess. The token is encrypted at rest — see ADR-2026-08-13-10F4L. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `accountId` | `string` | yes | Harvest's numeric account id, from the account's own settings. | | `accessToken` | `string` | yes | A personal access token, from the account's own settings. | | `confirmDifferentEmail` | `boolean` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### DELETE /api/v1/harvest Disconnect this person’s Harvest account Removes the stored token. Entries already exported to Harvest are unaffected. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Disconnected. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/harvest/exports Export time entries to Harvest Exports this person’s own entries only — the Harvest user is whoever the connected token belongs to. A per-entry failure is reported in the 200 response, not thrown; only a batch that cannot run at all (no connection, duration mode, Harvest unreachable) is a problem response. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryIds` | `string (uuid)[]` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestExportResult` | application/json | Per-entry results. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### POST /api/v1/harvest/exports/adopt Correct time entries to what Harvest holds The other answer to a divergence `POST /harvest/exports/compare` reports: instead of re-exporting Timjami’s version, take Harvest’s. Reads each entry’s linked Harvest copy and writes back only the fields that actually differ — times, note, and the task the stored mappings resolve Harvest’s project and task back to. Harvest is never written to. An entry the two already agree on answers `unchanged`; an invoice billing these hours refuses the times and the task the way `PATCH /time-entries/{id}` does, while the note stays correctable. A per-entry failure is reported in the 200 response; only a batch that cannot run at all (no connection, Harvest unreachable) is a problem response. `userId` corrects a named colleague’s entries instead of the caller’s (`time.importOthers`, ADR-2026-08-27-163AQ). See ADR-2026-08-19-06UZS. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryIds` | `string (uuid)[]` | yes | The entries to correct. The export’s cap rather than the comparison’s: this spends one Harvest request per entry and follows each with a local transaction. | | `personId` | `string (uuid)` | no | Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestAdoptResult` | application/json | Per-entry results. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### POST /api/v1/harvest/exports/compare Compare already-exported time entries against their live Harvest counterpart Reads each entry’s linked Harvest time entry and reports whether it still matches what an export would send. An entry with no export link, or one currently running again, answers `failed` — only an established link is comparable at all (see the export ADR). Harvest’s copies are read as one range and matched by link id; an entry that range does not hold is read on its own, and those individual reads are budgeted, so a batch that meets too many of them reports the rest rather than exhausting the rate limit (ADR-2026-08-18-14GLB). `userId` compares a named colleague’s entries instead of the caller’s, with the caller’s own token and `time.readOthers` (ADR-2026-08-27-163AQ). - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryIds` | `string (uuid)[]` | yes | The entries to compare, at most 2000. Far more than an export batch accepts, because a comparison reads Harvest’s copies as one range rather than one request per entry — see ADR-2026-08-18-14GLB. Equal to what one import takes over, so a whole reconciled period fits in both directions (ADR-2026-08-27-15EG4). | | `personId` | `string (uuid)` | no | Whose Harvest period this is about. Omit for the caller’s own. Naming somebody else requires `time.readOthers`, and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestCompareResult` | application/json | Per-entry comparison results. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### POST /api/v1/harvest/imports Import one person’s Harvest time entries for a day range Reads one person’s Harvest entries — `user_id` is always named explicitly, never omitted, because Harvest answers an administrator’s token with everybody’s hours otherwise — and books each one as a Timjami entry. Without `userId` that person is the caller and this is unchanged from what it always was. With one, it is the named colleague: the caller’s own connected token still reads (the colleague needs no Harvest connection of their own), their Harvest account is resolved by email fresh on every request, and every entry whose mapped task falls outside a project that person is assigned to answers `target-unassigned` rather than being booked — `time.importOthers` for the run, `time.readOthers` for a preview (ADR-2026-08-27-163AQ). `from`/`to` are Harvest’s `spent_date` bounds, both inclusive, so the range is meant in Harvest’s time zone. A Harvest entry is taken over only when the stored project and task mappings resolve backwards to exactly one Timjami task on that Harvest project; everything else is reported with a reason instead of guessed. Nothing is updated: a second run over the same range skips what is already here. An entry whose Timjami chain is archived is taken over as well — the one place time may be booked onto an archived customer, project or task, because these hours were tracked elsewhere while it was live; the result names the archived level in `archived` rather than refusing (ADR-2026-08-24-11766). Being assigned to the project is still required. `preview: true` computes the same answer and writes nothing. `harvestTimeEntryIds` narrows the run to a selection out of the range: the range is still read and placed whole — a placement depends on the previous entry of its Harvest day — and the answer then holds one result per requested id, `not-in-range` included (ADR-2026-08-20-142A9). Sits beside `/harvest/exports` rather than under the structure import `/imports/harvest`, which is a different thing with different authority. See ADR-2026-08-18-07A3Q. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | Inclusive. Handed to Harvest as its own `spent_date` bound unchanged, so the range is meant in Harvest's time zone — not the organization's. | | `to` | `string` | yes | Inclusive, and never before `from`. | | `preview` | `boolean` | yes | When `true`, nothing is written and every result says what would happen. | | `harvestTimeEntryIds` | `string[]` | no | Harvest time entry ids to take over, out of the range. Omit for the whole range. Given, the answer holds exactly one result per requested id, in this order — an id the range does not hold answers `not-in-range`. The range is still read whole, because an entry's reconstructed placement depends on the previous entry of its Harvest day. At most 2000. | | `personId` | `string (uuid)` | no | Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestImportResult` | application/json | Per-Harvest-entry results. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### DELETE /api/v1/harvest/links/{timeEntryId} Forget a time entry’s Harvest export link Clears a stuck claim after an ambiguous failure that was checked by hand, or a link whose Harvest entry was deleted. The next export creates a new Harvest entry rather than updating the one that is gone. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/harvest/links/by-harvest-entry/{harvestTimeEntryId} Forget the link of a Harvest entry whose Timjami entry was deleted Deleting a time entry in Timjami leaves its Harvest link behind with no entry — a tombstone — and the import reports that Harvest entry as `thymian-deleted` instead of booking the hours a second time. This forgets the tombstone, which is somebody deciding those hours should come back after all: the next import over their range creates a new entry for them. Addressed by the **Harvest** id, because the Timjami id is what the deletion took. A **live** link is never removed here — it stays reachable only through `DELETE /harvest/links/{timeEntryId}`, so no call to this endpoint can set an entry Harvest already holds up to be created there twice. See ADR-2026-08-27-148DF. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `harvestTimeEntryId` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Forgotten. The next import may fetch these hours again. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/harvest/projects List Harvest projects, for the mapping picker Reads live from Harvest, through this person’s stored connection. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestProjectList` | application/json | The Harvest projects. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/harvest/projects/{harvestProjectId}/tasks List a Harvest project’s task assignments, for the mapping picker Reads live from Harvest, through this person’s stored connection. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `harvestProjectId` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestTaskList` | application/json | The Harvest task assignments. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/harvest/seconds Give a period’s linked entries the seconds Harvest publishes only as hours Harvest tracks to the second but publishes an entry’s boundaries only as `HH:mm`, so entries imported before `POST /harvest/imports` booked Harvest’s own duration carry whole minutes — and no comparison flags them, because both sides agree on every `HH:mm`. This reads the period once and gives every already-linked entry the placement a fresh import would give it: a pair inside the two minutes Harvest published, as long as Harvest’s own `hours` says, and starting no earlier than the previous entry of that Harvest day ended. **The period is the unit on purpose** — that last rule is a property of a day, so a list of entry ids cannot honour it: the entry that has to yield is usually one that needs no correction of its own. Only `started_at` and `ended_at` move; no task, no note, and nothing is written to Harvest. An entry that already carries seconds of its own is refused as `not-minute-precise` rather than truncated to what Harvest holds, and an entry an invoice bills answers `billed` unless the caller may move invoiced hours. `preview: true` computes the same answer and writes nothing. `userId` corrects a named colleague’s entries instead of the caller’s (`time.importOthers`, ADR-2026-08-27-163AQ). See ADR-2026-08-20-07ME9. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `to` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `preview` | `boolean` | no | | | `personId` | `string (uuid)` | no | Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestSecondsResult` | application/json | Per-entry results for the period. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### GET /api/v1/harvest/summary What Harvest itself says this period adds up to Harvest’s **own** total for a day range, read from its time report — hours, billable hours and billable amount. Deliberately not a sum of the per-entry `hours` the import preview already returns: that figure quantises 0.01 h per entry, this one once per period, and it is the number Harvest’s own reports show. `from`/`to` are Harvest’s own report bounds, both inclusive, so the range is meant in Harvest’s time zone — the same as `POST /harvest/imports`. Answers one person’s hours: the report endpoint takes no user parameter, so an administrator’s token reads the whole team and the answer is filtered down here — to the connected Harvest user, or to the `userId` named, whose Harvest account is resolved by email (`time.readOthers`, ADR-2026-08-27-163AQ). The amount comes per currency and is never added across currencies. If the Harvest account has **Time Rounding** switched on, Harvest rounds these figures according to that setting — Harvest’s API does not say whether it is on. See ADR-2026-08-19-14RSH. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | Inclusive. Handed to Harvest as its own report bound, so the range is meant in Harvest's time zone — not the organization's. | | `to` | `string` | yes | Inclusive, and never before `from`. | | `personId` | `string (uuid)` | no | Whose Harvest period to total up. Omit for the caller’s own. Naming somebody else requires `time.readOthers`; the report itself takes no user parameter, so the caller’s token reads the whole team and the answer is filtered down to this person’s Harvest account, resolved by email address. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestPeriodSummary` | application/json | Harvest’s own totals for the range. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### GET /api/v1/projects/{id}/harvest Read a project’s Harvest mapping The Harvest project this project exports to, or `null` when unlinked. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestProjectLink` | application/json | The mapping, or `null`. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/projects/{id}/harvest Point a project at a specific Harvest project Filled in automatically on import; use this for a project Harvest has no matching record for, or to correct a mismatch. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `harvestProjectId` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestProjectMapping` | application/json | The mapping. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/projects/{id}/harvest Forget a project’s Harvest mapping - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/projects/{id}/harvest/imports Import a project’s Harvest hours for every one of its current members Reads with the caller’s own Harvest connection — which must already carry Administrator/Manager rights in Harvest, since this reads every matched member’s hours, not just the caller’s own — and matches each current project member to a Harvest account by email, fresh on every run. `from`/`to` are Harvest’s `spent_date` bounds, both inclusive, in Harvest’s time zone, exactly as `POST /harvest/imports`. Every read is scoped to this project’s linked Harvest project, so a member’s hours on any other Harvest project never leak in. A whole-run failure (no connection, a dead one, duration-mode, no Harvest project link, or `GET /users` refused) is a problem response; a member with no or an ambiguous Harvest match, or whose hours Harvest refuses to this token, is reported per person and the run continues for the rest; a per-entry skip uses the same vocabulary `POST /harvest/imports` does, plus `target-unassigned` for a mapped task outside this project. `otherTrackers` additionally names everybody who tracked hours here in the range and is not among `members` — a person who tracked on this project but is not (yet) a project member, so this run never booked anything for them; a failure reading this diagnostic never fails the run. `preview: true` computes the same answer and writes nothing. See ADR-2026-08-27-09BL7, ADR-2026-08-27-13NS7. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | Inclusive. Handed to Harvest as its own `spent_date` bound unchanged, so the range is meant in Harvest's time zone — not the organization's. | | `to` | `string` | yes | Inclusive, and never before `from`. | | `preview` | `boolean` | yes | When `true`, nothing is written and every result says what would happen. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestProjectTeamImportResult` | application/json | Per-member, per-entry results. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/tasks/{id}/harvest Point a task at a specific Harvest project and task Filled in automatically on import; use this for a task Harvest has no matching record for, or to correct a mismatch. - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `harvestProjectId` | `string` | yes | | | `harvestTaskId` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HarvestTaskMapping` | application/json | The mapping. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/tasks/{id}/harvest Forget a task’s Harvest mapping - Scope: `thymian:projects.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Reports Aggregates over the organization, gated to owners and admins — unlike the time entries they are built from, which every member can already read. ### GET /api/v1/agent-usage Coding-agent token usage for a month or a day Tokens Claude Code and Codex spent in one month, by attribution, by model and by day. Name `day` to narrow the month to one of its days. Attribution is derived when reading: the time entry the session’s hint was accepted into comes first, else the longest matching hint rule of that agent naming a task, project or customer, else unattributed. Usage attributed to an entry, task, project or customer the reader may not see counts as `hidden`. Name `personId` to read a colleague’s usage; that needs the right to read their time, and the answer never includes path labels. - Scope: `thymian:read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `day` | `string` | no | One day of `month`, in the organization’s time zone. Omit for the month. | | `personId` | `string (uuid)` | no | Person identifier (UUID v4) — a person, not a login. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AgentUsageSummary` | application/json | The month’s or the day’s usage. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/reports/company-metrics The weekly „Kennzahlen": two rates and revenue per FTE of sold capacity Readable by **every member** — these are figures the organization publishes to itself. Three of them over the four whole weeks before the current one, each with the same figure over the four weeks before those, so a client can render a trend without asking twice. `fte` and `warnings` are absent for a caller without `report.readOthers` — the first because the organization publishes one set of figures rather than the inputs behind them, the second because it names colleagues (ADR-2026-09-11-10ZC4, ADR-2026-09-11-12G8M). **Interim.** These definitions reproduce one organization’s existing weekly post rather than expressing a decision about what Timjami measures. See ADR-2026-08-28-08WY9 for what is expected to change and what would trigger changing it. The window does not move during a week: Monday through the following Sunday all return the same one, and a Sunday still running never counts as a finished week. It follows the organization’s `weekStartsOn` and its time zone. Both rates cover internal people flagged `inTeamMetric` alone and are percentages from 0 to 100. `revenuePerFte` covers every `internal` person — those outside the measured group included — and is integer minor units already divided by the capacity. That asymmetry is intended: a freelancer’s revenue was not produced by the internal capacity the denominator measures. Capacity is resolved per day over the window itself rather than from a monthly figure: every internal person’s dated working time, scaled by their `billableCapacityShare`, against the organization’s default working-time model. A contract that changed inside the window therefore lands on the right days. Absences do not reduce it — this is contracted, sold capacity, not time worked. A figure whose comparison window produced no value is reported as `null` throughout rather than alone, and never as zero. `warnings` names the four ways a figure could be understated or the denominator too generous. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `CompanyMetrics` | application/json | The weekly company metrics. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/company-metrics/months The „Kennzahlen" by calendar month Readable by **every member**. The same figures as `GET /reports/company-metrics`, folded over whole calendar months instead of four finished weeks — the shape a monthly spreadsheet is filled from. A caller without `report.readOthers` receives neither `warnings`, which names colleagues, nor `fte`, `revenueMinor`, `targetRevenueMinor` and `measuredSeconds`, which are left out to keep one published set of figures rather than to protect them — `revenueDeviationMinor` stays, and capacity and the undivided total remain derivable from the figures beside it (ADR-2026-09-11-10ZC4). **A second window, not a second definition.** The entries, the valuation, the two populations and the capacity model are identical; only the bounds differ. A month’s figure will therefore rarely equal any week window’s, and neither is wrong. See ADR-2026-09-07-095QW, and ADR-2026-08-28-08WY9 for the figures themselves. Rows come back newest first and include the month the request falls in, marked `complete: false`: its figures cover the days so far and are not comparable with the finished months below them. Capacity is resolved over each month’s own days, so a short month is not reported as a revenue movement, and `warnings` is per month for the same reason — a colleague without a working-time model inflates only the months they were there for. `billableRateDeviation` and `revenueDeviationMinor` measure against the organization’s configured targets and are `null` where none are configured. The rate deviation is `0` inside the corridor, negative below its floor and positive above its ceiling; the revenue deviation compares the month’s undivided total against the per-FTE target times that month’s capacity. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `months` | `integer` | no | How many calendar months to return, 1–36. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `CompanyMetricMonths` | application/json | The company metrics by calendar month. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/company-metrics/weeks The „Kennzahlen" by whole week Readable by **every member**, and narrowed for a caller without `report.readOthers` exactly as the monthly table above is (ADR-2026-09-11-10ZC4). The same figures as `GET /reports/company-metrics`, folded over single whole weeks — the finest of the three readings, and the only one the published four-week figure decomposes into: four consecutive rows whose `complete` is true are exactly what that read covers. See ADR-2026-09-11-07NGQ, and ADR-2026-08-28-08WY9 for the figures themselves. A week starts on the organization’s own `weekStartsOn`, like the four-week window, so `weekStartDay` is not necessarily a Monday. That is deliberately not the ISO week a plan allocation is keyed by: a plan is shared between people who may display the week differently, while these rows are one organization reading its own published figure. Rows come back newest first and include the week the request falls in, marked `complete: false`: its figures cover the days so far and are not comparable with the finished weeks below them. Capacity is resolved over each week’s own days and `warnings` is per row, as in the monthly table. Note that a single week is a noisier row than a month by nature: a public holiday sells the same capacity while less time is tracked, and a monthly allowance reaches a week as its calendar-day share. Nothing here smooths either. `billableRateDeviation` and `revenueDeviationMinor` measure against the organization’s configured targets and are `null` where none are configured. The revenue target is the agreed monthly figure at one week — `× 12/52`, the same conversion the four-week window uses at four weeks — while the rate corridor is unconverted. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `weeks` | `integer` | no | How many whole weeks to return, 1–104. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `CompanyMetricWeeks` | application/json | The company metrics by whole week. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/dashboard The organization's live management dashboard Requires the `owner` or `admin` role. Returns one consistent snapshot of provisional recorded revenue and today’s attendance across the complete organization. Revenue is the sum of the current valuation of entries starting in the requested organization-local day, week and month. It includes running timers measured at `asOf`, is not an invoice or accounting figure, and reports billable time without a rate separately instead of treating it as zero. Attendance resolves every member’s dated working-time model, including monthly pro-rata days, then removes absences while the organization has them on. `notStartedPersonIds` contains only people who were scheduled, have recorded no time and, while absences are on, have no absence covering the day. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `day` | `string` | no | The organization-local day to inspect. Defaults to today in the organization's time zone. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OrganizationDashboard` | application/json | The organization dashboard snapshot. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/reports/dashboard/widgets The figures a dashboard's widgets ask for, in one read Requires the `owner` or `admin` role and a session: the request vocabulary follows the web app’s widgets and is not public API (ADR-2026-10-13-08726). Every request is answered from one entry read over the union of their windows, valued once at `asOf`. An entry counts in the window it starts in, with its whole value; a running period ends with `day`, and its comparison stops at the same wall-clock point of the previous period. Revenue is provisional, like `GET /reports/dashboard`. A kind gated by a capability or an organization feature the caller lacks is answered `{ kind: "withheld", requested, reason }` instead of its figure, and its source is not read; the rest of the batch is answered as asked (ADR-2026-10-27-10CQS). - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `day` | `string` | no | The organization-local day the periods are read for. Defaults to today in the organization's time zone. | | `requests` | `(object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object)[]` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `DashboardWidgets` | application/json | One result per request, in request order; none for a request of a kind this server does not know. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/hours-by-project Tracked hours by project in a window Requires the `owner` or `admin` role. The report covers the complete organization, including private and archived projects that have tracked time in the window. One row per project with an entry starting in `[from, until)`, ordered by tracked seconds descending. Running entries contribute no seconds until they are stopped, matching every other duration summary. `totalSeconds` is the sum of the returned rows. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | yes | Exclusive upper bound on startedAt. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Tracked hours grouped by project.`{ data: ProjectHours[], totalSeconds: integer }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/hours-by-user Every member's tracked hours in a window Requires the `owner` or `admin` role — this is a management question, not a read every member gets, unlike the unfiltered `GET /time-entries` (invariant 19). One row per person who has at least one entry in `[from, until)`; a member with none is simply absent, not a row of zeroes. `totalSeconds` excludes running entries the same way `/time-entries/summary` does. `lastEntryAt` is the start of that person's most recent entry *inside* the window, and it does move when a timer is merely running, since that entry is more recent than any finished one. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | yes | Exclusive upper bound on startedAt. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Every member's hours.`{ data: UserHours[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/hours-over-time Hours over time on one project or customer Finished entries on one project or one customer in `[from, until)`, summed into weeks, months or quarters and split by person, by project (customer only) or by settlement status. Every bucket of the range is present, an empty one as zero; without `from` the range starts at the subject’s first entry. A running entry contributes nothing until it is stopped. Buckets are the *organization’s* local days, and weeks start on its `weekStartsOn`. Only projects the caller can see count: a private one for its members and for `project.readPrivate`. This report carries hours only: no rate, no amount, no currency (ADR-2026-10-24-174SX). `series` is ordered by all-time seconds on the subject, whatever the window, so a series keeps its place as the period changes. Without `report.readOthers` (the owner or admin role), `groupBy=person` answers two series only, `self` and `others` — every colleague folded into `others`, even when that is one colleague — and no other person’s id or name appears anywhere in the answer. `groupBy=settlement` splits every second into `billed` (an invoice, drafts included, or an external claim), `written-off`, `open` (unclaimed on an hourly task) or `not-hourly`, and is refused with a `403` without `invoice.read` — never answered as „nothing to bill". With neither `from` nor any hours the answer is empty, labelled `month`. The window must lie within 1 January 2000 and 31 December 2099 in the organization’s zone, and `until` must be after `from`; anything else is refused with a `422`. The widest window is therefore 400 quarters. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | no | The project to read. Exactly one of `projectId` and `customerId`. | | `customerId` | `string (uuid)` | no | The customer to read, across every project of it the caller can see. Exactly one of `projectId` and `customerId`. | | `groupBy` | `"person" \| "project" \| "settlement"` | yes | What a bar is split by. `project` needs `customerId`; `settlement` needs `invoice.read`. | | `from` | `string (date-time)` | no | Inclusive lower bound on `startedAt`, no earlier than 1 January 2000 in the organization’s zone. Omitted: from the subject’s first entry on. | | `until` | `string (date-time)` | yes | Exclusive upper bound on `startedAt`, after `from` and after 1 January 2000, and no later than 1 January 2100, in the organization’s zone. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HoursOverTime` | application/json | The subject’s hours, bucketed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/monthly-statistics/export Download hours and revenue by month, person, customer and project as CSV Requires the `owner` or `admin` role: it names every person’s revenue, and those roles see private projects, so no project is silently missing. See ADR-2026-10-01-15X25. One row per month, person, customer, project and currency, for every whole month in `[from, until]` — at most 36. A month is the month the work was done in, never the month it was invoiced; an allowance („Pauschale") counts in its own month, and both its hours and its revenue are also shown on their own. **Billed** means a live claim of a **transferred** invoice or an `external` claim („Extern abgerechnet"); a draft counts as open. A billed or written-off stretch is priced at its frozen rate and filed under the currency it was settled in; everything else at today’s rate. **Umsatz** is open plus billed and never includes written-off money, which has its own columns; `Umsatz offen` is `Umsatz − Umsatz abgerechnet`. A billed or written-off stretch is billable even if its task is not any more. `Stundensatz` divides revenue by the billable hours **with** a rate less the written-off ones; `effektiver Stundensatz` by every hour. Both are empty where the divisor is zero. Money rounds once per row, status, source and rate. Running entries count nowhere. Columns: `Monat`, `Person-ID`, `Person`, `Kunde-ID`, `Kunde`, `Projekt-ID`, `Projekt`, `Währung`, `Stunden gesamt`, `davon Pauschale (h)`, `abrechenbar mit Satz (h)`, `abrechenbar ohne Satz (h)`, `nicht abrechenbar (h)`, `Anteil abrechenbar (%)`, `Umsatz`, `davon Pauschale (Umsatz)`, `Stundensatz`, `effektiver Stundensatz`, `abgeschrieben (h)`, `Umsatz abgeschrieben`, `abgerechnet mit Satz (h)`, `abgerechnet ohne Satz (h)`, `Umsatz abgerechnet`, `offen (h)`, `Umsatz offen`. `personNames`, `customerNames` and `projectNames` set to `false` each drop that name column from the header and every row; the matching id column always stays, so rows still join back and two people with the same name are never merged. Hours are decimal with four places, money in major units, the share in percent with one place. Semicolon-delimited, comma decimals, UTF-8 with a byte-order mark — Excel’s German-locale default, like `GET /time-entries/export`. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | The first month, `YYYY-MM`, inclusive. | | `until` | `string` | yes | The last month, `YYYY-MM`, inclusive. | | `personNames` | `string` | no | `false` leaves out the `Person` column; `Person-ID` stays. | | `customerNames` | `string` | no | `false` leaves out the `Kunde` column; `Kunde-ID` stays. | | `projectNames` | `string` | no | `false` leaves out the `Projekt` column; `Projekt-ID` stays. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string` | text/csv | One row per month, person, customer, project and currency. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/person-activity What one person worked on in a window Finished entries in `[from, until)`, folded into project, task and note totals plus a per-organization-local-day breakdown. Defaults to the caller; naming a *specific other* person’s `personId` requires `time.readOthers` (the owner or admin role) — reading your own activity needs neither, which is why this route sits beside `hours-by-project` and `hours-by-user` rather than sharing their gate (ADR-2026-09-08-16W39). A running entry contributes no seconds, no note, and no day until it is stopped — the same rule every other duration summary in this API follows. `perDay` carries only days that have time; a day with none is simply absent, never a row of zero. A day, a `firstDay` and a `lastDay` are the *organization’s* local calendar day, resolved from `startedAt` — never UTC, which would misfile a late evening under the next day. A task’s `notes` never carries more than the five notes with the most seconds; `notesTruncated` says whether more existed. An entry with no note, or a blank one, contributes its seconds to the task’s total and to nothing under `notes`. This report carries no money: no rate, no amount, no currency (ADR-2026-09-08-16W39). The deprecated `userId` alias naming an account this organization has no person for answers a `200` with an empty report and `personId: null`, exactly like `GET /time-entries` answers the identical alias — never an error, and never a hint about which ids exist. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | The subject. Defaults to the caller; naming a specific *other* person requires `time.readOthers` (the owner or admin role) — the same gate the entries list uses for the same parameter. | | `from` | `string (date-time)` | yes | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | yes | Exclusive upper bound on startedAt. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PersonActivity` | application/json | The person's activity. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/person-revenue One named person's provisional revenue over a window Requires `revenue.readOthers` (the owner or admin role) for *every* subject — unlike `/reports/person-activity`, there is no own-data reading here at all, so `personId` is always required and always checked against the gate. The figure is the sum of the current valuation of the subject’s billable entries in `[from, until)`, at current rates — provisional until an invoice is transferred, the same wording `/reports/dashboard` uses. `unratedSeconds` reports billable time no rate resolved for, rather than treating it as zero. `includeRunning` folds the subject’s own running entry in, valued once as of the moment of the request. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | The subject. Always required — unlike `personActivityQuerySchema`, this endpoint has no own-data reading: naming yourself still requires `revenue.readOthers`. | | `from` | `string (date-time)` | yes | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | yes | Exclusive upper bound on startedAt. | | `includeRunning` | `string` | no | Fold the subject's own running entry into the figure, valued once as of now — see runningIncluded on the result. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PersonRevenue` | application/json | The person's revenue over the window. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/projection One month of the analytics projection, complete Requires the `owner` or `admin` role, for the reason the delta endpoint gives. The month's current state in full: one document per valued stretch of every finished entry, non-billable time included. Tombstones are not in it — a full export is a statement about what exists. **The first page recomputes and stores**, exactly as the delta does, and every page after it reads what that run stored. That is what makes `syncedAt` usable: pass it as the next `since` and the delta continues from this export with nothing in between. It is also what keeps the pages consistent — recomputing per page would let page three describe a different world than page one. Page with `cursor` while `nextCursor` is not null. Use this endpoint for a first sync, for a rebuilt index, and for the nightly full run. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | The month to export, `YYYY-MM`. | | `limit` | `integer` | no | | | `cursor` | `string` | no | Keyset cursor: pass the previous page's `nextCursor` back unchanged. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The month's documents.`{ data: ProjectionDocument[], nextCursor: string \| null, syncedAt: string }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/projection/changes Everything the analytics projection changed since your last pull Requires the `owner` or `admin` role, like every other read over the whole organization's data. Those roles see private projects, which is what makes the export complete — a projection missing a project because of whose token the cron holds is wrong in the way nobody notices. **This request recomputes and stores.** Every month is recomputed, diffed against what was last delivered, and the new state persisted; tombstones past 30 days are pruned. There is no scheduler in Timjami, so the pull is the clock. The answer is every stored document — tombstones included — that changed at or after `since`, **not** merely what this run changed. Advance your cursor only after your own write succeeded: asking again with the same `since` repeats the delivery instead of losing it, and every upsert is idempotent. **Act on `deleted`.** A document with `deleted: true` carries no body and means delete this id — hours deleted or re-dated in Timjami otherwise stay in your index forever. A re-dated entry arrives as exactly that: a tombstone in the old month and a document in the new one. Page with `cursor` while `nextCursor` is not null, keeping `since` unchanged. When it is null the delta is complete; store `nextSince` and pass it as the next pull’s `since`. `fullResyncRecommended` means your `since` reaches back further than the tombstones that would have explained it — call the full export instead of trusting this answer. - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `since` | `string (date-time)` | yes | The previous pull's `nextSince`. Inclusive, so the last delta is re-delivered once — upserts make that free, and a strict comparison would lose a document whenever two runs stamp the same instant. | | `limit` | `integer` | no | | | `cursor` | `string` | no | Keyset cursor *within this delta*: pass the previous page's `nextCursor` back unchanged, together with the same `since`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The changed documents.`{ data: ProjectionChange[], nextCursor: string \| null, nextSince: string, fullResyncRecommended: boolean }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/projects-by-user Every member's project count Requires the `owner` or `admin` role, the same gate as `hours-by-user` — how many projects somebody is assigned to is a management question, not a read every member gets about every colleague. A snapshot of `project_member` right now, not a window — there is no `from`/`until`. One row per person assigned to at least one project; a member with none is simply absent, not a row of zero (invariant 19). - Scope: `thymian:reports.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Every member's project count.`{ data: UserProjectCount[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/tickets Tracked hours by external ticket One row per ticket — per `(provider, key)` **pair**, never the key alone: `TIM-123` in Linear and `TIM-123` in Jira are two tickets, and merging them would invent a total nobody worked (invariant 73). Readable by every member, and narrowed the way the entries list is: hours on a project the caller cannot see are not in it, and their own hours always are (invariant 51). Two people therefore get different totals for the same ticket, which is the accepted cost of a member-readable sight. **Time only, never money.** A reference reaches no invoice line and is no grouping key for one, so there is no amount, no rate and no currency here. Ordered by tracked seconds descending and capped at 500 rows; `truncated` says the window held more. Running entries contribute no seconds — `durationSeconds` is not fixed until a timer stops — while they do count in `entryCount` and can move `lastStartedAt`. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | yes | Exclusive upper bound on startedAt. | | `personId` | `string (uuid)` | no | Only entries tracked by this person. | | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | no | | | `key` | `string` | no | One ticket, read in the spelling its system uses — `eng-1` finds `ENG-1`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Tracked hours grouped by ticket.`{ rows: TicketReportRow[], truncated: boolean }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/reports/tickets/days Tracked hours by day and external ticket The same hours `/reports/tickets` sums, cut finer: one row per **local day and ticket**, which is the shape a month is transcribed into another system in — on this day, this long on this ticket. Everybody’s hours on the ticket that day are one row, and `personIds` names who worked on it, as `/reports/tickets` does; pass `personId` for one person’s days. The day is the **organization’s**, not UTC: a late evening files under the day whoever tracked it was living in, and `timeZone` travels beside the rows so the column is labelled with the zone the buckets were cut in. `from` and `until` still bound `startedAt` as instants, so pass the month’s own local boundaries and the window lines up with the days it is cut into. An entry carrying two tickets appears under **both**, counted whole each time — it was one session of work that was about two of them, and apportioning its seconds between them would invent a division nobody tracked. Totals across rows therefore need not add up to the window’s tracked time. `rounding=carry` adds `carriedMinutes` to every row: whole minutes that add up to the window’s rounded grand total exactly, with each ticket within a minute of its own seconds after every day — its remainder is carried into its next day rather than lost to flooring each row. Readable by every member and narrowed exactly as `/reports/tickets` is (invariant 51), and, like it, **time only, never money**. Ordered by day, then by ticket, and capped at 2000 rows; `truncated` says the window held more, and carried minutes then balance over the rows returned. Running entries are in no figure until they are stopped. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | yes | Exclusive upper bound on startedAt. | | `personId` | `string (uuid)` | no | Only entries tracked by this person. | | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | no | | | `key` | `string` | no | One ticket, read in the spelling its system uses — `eng-1` finds `ENG-1`. | | `rounding` | `"carry"` | no | Absent: rows carry seconds only and `carriedMinutes` is null. `carry`: every row also carries whole minutes. All of them add up to the returned rows’ seconds rounded to the minute — the window’s total whenever `truncated` is false — and each ticket’s minutes stay within a minute of its own seconds after every day, its remainder carried into its next day. The remainder starts at `from`, so the total is exact for the window that was asked, not for a sum of smaller ones. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Tracked hours grouped by day and ticket.`{ rows: TicketDayReportRow[], truncated: boolean, timeZone: string }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Hints What collectors observed you working on, offered as suggestions for time entries. Visible only to the person they belong to. ### GET /api/v1/hint-rules List your hint rules Your own declared answer to "what does this subject mean", for every `(source, subject)` pair you have classified — never anybody else’s: a rule is a judgement about your own hint stream, exactly as private as the hints it classifies (ADR 0024). Resolve a `taskId` against the task list you already have, the way a stale `taskSuggestion` resolves to nothing rather than an unresolvable id: this endpoint does not say whether the task is still live (see `GET /hints` `taskSuggestion`). - Scope: `thymian:hints.read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Your hint rules.`{ data: HintRule[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/hint-rules Declare (or replace) what a subject or path means One rule per `(source, path)` pair — sending the same pair again replaces the classification rather than adding a second rule. A `path` covers everything under it (a rule on `Workspace → Team` also covers `Workspace → Team → Issue`); omitting `path` behaves as a path of length one built from `subject`, folded case-insensitively for backward compatibility. `presence` means the subject is evidence that you worked, not what on: it carries no task and the panel offers no suggestion for it. `task` names one task outright, and always outranks the derived suggestion `GET /hints` would otherwise offer for the same path. `ignore` hides it from the panel. `project`/ `customer` narrow the task picker to that project or customer without naming a task outright — evidence one level up from `task`. `absence` is the declared opposite of `presence` — evidence that you did *not* work; the panel offers it beside a collector-reported `signal: absence` hint, dismiss-only. The target field matching `kind` is required and every other target field is refused. `taskId`/`projectId` must be one you are assigned to (or may write any project) — the same visibility and assignment rule new time entries follow; `customerId` only has to exist, since a customer carries no visibility or membership model of its own (invariant 51 only restricts projects and tasks). An **archived** target may still be named: naming is not booking (invariant 54), so the rule stands and simply offers nothing to preselect once its target is archived. - Scope: `thymian:hints.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `subject` | `string` | yes | | | `path` | `HintPath` | no | | | `kind` | `"presence" \| "task" \| "ignore" \| "customer" \| "project" \| "absence"` | yes | `presence` = evidence that you worked, not what on. `task` = names one task. `ignore` = noise, hidden from the panel. `customer`/`project` = narrows the task picker to that customer or project without naming a task. `absence` = evidence that you did *not* work — the declared opposite of `presence`. | | `taskId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HintRule` | application/json | The rule. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/hint-rules/{id} Delete a hint rule Never archived — a rule is a setting, not a record. Deleting it does not touch any hint it classified; the subject simply goes back to unclassified. - Scope: `thymian:hints.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The rule was deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/hints List your hints Newest first, and only the calling user's own hints — there is no way to read anybody else's. Filtered by `startedAt` like time entries, so a client showing one day asks for that day. A hint whose window spans midnight is therefore listed under the day it *started* on. Paginate with `startedBefore` set to the previous page's `nextCursor`. `q` is a rudimentary case-insensitive substring match against `subject` and `details`. Each hint carries a `taskSuggestion` when **you** have booked a hint with the same `subject` before: the task the most recent such entry belongs to, with how many of your earlier bookings agree. It is derived from your own resolved hints and nobody else’s, and a subject nobody has booked simply has `null` — Timjami does not guess a task from a repository name (ADR 0030). - Scope: `thymian:hints.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `source` | `string` | no | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `q` | `string` | no | Case-insensitive substring match against subject or details. | | `from` | `string (date-time)` | no | Inclusive lower bound on startedAt. | | `until` | `string (date-time)` | no | Exclusive upper bound on startedAt. | | `resolved` | `string` | no | false = only hints still to act on, true = only resolved ones. | | `limit` | `integer` | no | | | `startedBefore` | `string (date-time)` | no | Keyset cursor from nextCursor. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The hints.`{ data: HintWithSuggestion[], nextCursor: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/hints Report a hint For collectors: a Claude Code hook, a browser extension, an editor plugin. The hint is filed against the caller and the **active organization**, which is what keeps one client’s evidence out of another client’s day. A collector reporting after leaving one organization and joining another needs its session pointed at the new one first, which `timjami login` does on its own — a user belongs to at most one at a time. Reporting the same `source` and `externalId` again **widens the existing hint** — `startedAt` moves to the earlier value, `endedAt` to the later one, and `occurrenceCount` goes up — instead of creating another row. So a session that posts on every message ends up as one hint spanning the session. A dismissed hint stays dismissed. Reporting the same `clientHintId` again **replaces the hint instead of widening it** — meant for a client that must retry a submission after a dropped connection without risking a duplicate. Unchanged, the retry is a no-op; changed, it overwrites `subject`, `details` and the window, bumps `version`, and clears any resolution — the person has not seen the content that replaced what they accepted or dismissed. Independent of `externalId` widening: a report with a `clientHintId` never widens, whether or not it also carries an `externalId`. A `sessionKey` keeps every report its own observation and only groups the session’s reports into one stretch when hints are read; it cannot be combined with a `clientHintId`. Answers `200` when an existing hint was matched (widened or replaced) and `201` when a new one was created. **Reporting also enforces retention.** Your hints older than the organization’s `hintRetentionDays` are deleted as part of this request — including ones already turned into a time entry, which is untouched. Expiry rides along with reporting because Timjami has no scheduler (ADR 0025). - Scope: `thymian:hints.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `externalId` | `string \| null` | no | Stable id from the collector, e.g. a session id. | | `clientHintId` | `string \| null` | no | The reporting app’s own idempotency key for this report. | | `sessionKey` | `string \| null` | no | Groups this report with the same session’s other reports; every report is its own observation. | | `subject` | `string` | yes | Short label: what the collector thinks the person was working on. | | `path` | `HintPath` | no | | | `details` | `string \| null` | no | Optional longer context — a URL, the files touched, the prompt. | | `startedAt` | `string (date-time)` | no | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | no | ISO-8601 timestamp with offset. | | `signal` | `"presence" \| "absence"` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Hint` | application/json | An existing hint was widened or replaced. | | 201 | `Hint` | application/json | The reported hint. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/hints/{id} Get a hint - Scope: `thymian:hints.read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Hint` | application/json | The hint. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/hints/{id} Delete a hint Hints are deleted, not archived: nothing is attributable to them, and they are the most privacy-sensitive rows Timjami stores, so "delete" means gone. - Scope: `thymian:hints.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The hint was deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/hints/{id}/dismiss Dismiss a hint, or take that back Dismissing clears any link to a time entry: a hint is either time, or not relevant, never both. The entry itself is untouched. - Scope: `thymian:hints.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `dismissed` | `boolean` | yes | true = not worth tracking. false takes the dismissal back. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Hint` | application/json | The hint. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/hints/{id}/link Record that a hint became a time entry Called after creating or correcting an entry from a hint, so the hint stops being offered. `null` unlinks it again. The entry must be one of **your own** in the active organization. Linking clears a dismissal, for the same reason dismissing clears a link. - Scope: `thymian:hints.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryId` | `string (uuid) \| null` | yes | The time entry this hint became, or null to unlink. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Hint` | application/json | The hint. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/hints/evaluate Evaluate selected hints with the configured LLM Runs only for this explicit request and only while both the organization and person currently consent. Returns transient suggestions; it changes no hint or time entry. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `hintIds` | `string (uuid)[]` | yes | This person’s own hints to evaluate — up to 50 per request. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `HintEvaluation` | application/json | Transient, reviewable suggestions. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422, 429, 503. ### GET /api/v1/hints/sources List the distinct sources across your hints The `source` slugs your own hints currently carry, alphabetically — what the hints list’s „Nach Quelle filtern" suggestion is built from. `hintSourceSchema` is an open vocabulary (ADR 0024): a custom collector reporting an unlisted slug appears here exactly like a built-in one the moment it has reported anything, and one that stopped reporting (or aged out of retention, ADR 0025) drops out on its own. - Scope: `thymian:hints.read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The observed sources.`{ data: string[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/hints/subjects List the distinct (source, path) pairs in your hints The categorization screen's worklist: every `(source, path)` pair your hints currently show, with how much of it you have, ordered by observed seconds — deliberately not by `occurrenceCount`, which a re-polling collector can inflate without there being any more to see (tracked separately, not fixed here). Grouped by `path`, not by the flat `subject` alone, so two things sharing a leaf label under different parents stay two rows (TIM-48). Carries no classification: pair this with `GET /hint-rules` to see which of these already have one. A pair can appear here with no rule, and a rule can exist for a pair that stopped appearing here once its hints aged out of the retention window — the two lists answer different questions on purpose. - Scope: `thymian:hints.read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The observed subjects.`{ data: HintSubject[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Collectors Getting a collector installed. The hook scripts are served here so a project that is not a Timjami checkout can fetch them. ### GET /api/v1/collectors/google-calendar Your Google Calendar connection Whether this deployment is set up for Google at all, whether **you** have connected an account, and which of its calendars are observed. A connection with no calendars reads nothing — that is the state a fresh connection starts in. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `GoogleCalendarConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PATCH /api/v1/collectors/google-calendar Which calendars are observed, and what may be recorded about them A field you omit is left as it is. `calendarIds` replaces the whole selection, and an empty array is valid: connected, reading nothing. Turning `includeDetails` off also clears the location and description already stored on your calendar hints. `POST /hints` coalesces `details` so a later report never erases it, which would otherwise leave the switch governing only future events. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `calendarIds` | `string[]` | no | Which calendars are observed. An empty array is valid and means nothing is read — there is no value meaning "all". | | `includeDetails` | `boolean` | no | Whether a hint may carry the calendar's name, the event's location and the first line of its description. Off by default; turning it off also clears what is already stored. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `GoogleCalendarConnection` | application/json | The updated connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/collectors/google-calendar Disconnect your Google account Revokes the grant at Google as well as deleting it here — a token we merely forgot our copy of still works. Hints already reported stay until they expire, and can be dismissed or deleted like any other. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Disconnected. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/collectors/google-calendar/calendars The calendars you could observe Read from Google each time rather than stored: a calendar somebody was added to yesterday has to appear, and a stale copy would offer one they can no longer read. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `GoogleCalendarList` | application/json | The calendars. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/collectors/google-calendar/connect Start connecting your Google account Returns the URL to send the browser to. The Google account that authorises is the one bound to you, in the organization active on this session — which is why this is an OAuth round trip and not an address you type. Timjami asks for two read-only scopes and no others: the list of your calendars, so the card can offer it, and the events on them. Nothing it is granted can write to a calendar. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `GoogleCalendarAuthorizeUrl` | application/json | Where to send the browser. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/collectors/google-calendar/sync Read the calendar now The collector normally syncs by itself, riding along with a read of `GET /hints` — Timjami has no scheduler, so the moment somebody looks at the panel is the moment their calendar is read. This is the same work, awaited, for the button that says so. Unlike the automatic one it is **not** throttled: a control that exists for "it does not seem to be working" must do something every time it is pressed. Answers how many events became a hint, and why none did when none did. Reporting nothing is the normal answer for a day with no meetings. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `GoogleCalendarSync` | application/json | What the sync found. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/collectors/keys List your collector keys Your own keys for the active organization, never anybody else’s. `lastRequestAt` is what answers "is this thing actually reporting?". The key itself is not here and cannot be: only a hash is stored. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The keys.`{ data: CollectorKey[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/collectors/keys Create a key for a collector Creates the credential a collector reports with, bound to **you** and to the **active organization**. The key is in the response and nowhere else — it is stored hashed, so losing it means creating another. What the key can do is one thing: `POST /api/v1/hints`. It cannot read hints back, and it cannot touch anything else in the API — which is why a collector gets one of these rather than a session token (ADR 0028). It does not expire. Revoke it with `DELETE /collectors/keys/{id}`. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | How this key is labelled in the list, e.g. the machine it lives on. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CreatedCollectorKey` | application/json | The new key, shown once. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/collectors/keys/{id} Revoke a collector key The collector stops being able to report immediately. Hints it already reported stay — they are observations about work that happened, not property of the key. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The key was revoked. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/collectors/slack The Slack connection, for this organization and for you Answers three questions at once: is this deployment set up for Slack, is a workspace connected, and are *you* connected to it. Only the last one decides whether your messages are observed. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/collectors/slack Disconnect the workspace Removes the workspace and everybody’s link to it, and revokes the token at Slack — deleting our copy would leave a working credential behind. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Disconnected. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/collectors/slack/connect Start connecting your own Slack account Returns the URL to send the browser to. The Slack account that authorises is the one bound to you — which is why this is an OAuth round trip and not a name you type. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackAuthorizeUrl` | application/json | Where to send the browser. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/collectors/slack/huddle Start reporting when you are in a Slack Huddle Its own round trip, requesting the same `users:read` permission `presence` does — kept separate because being in a Huddle and showing as active are different evidence, and somebody may want to report one without the other. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackAuthorizeUrl` | application/json | Where to send the browser. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/collectors/slack/huddle Stop reporting when you are in a Slack Huddle Nothing to take down at Slack — a Huddle is only ever read as an event. The token is shared with the status and presence features, so it is only forgotten and revoked once both of those are switched off too. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Switched off. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/collectors/slack/identity Whether your hints may carry what you wrote Turning this off also clears the message text already stored on your Slack hints. `POST /hints` coalesces `details` so a later report never erases it, which would otherwise leave the switch governing only future messages. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `includeMessageText` | `boolean` | yes | Whether a hint may carry the first line of what you wrote. Off by default; turning it off also clears the text already stored. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackConnection` | application/json | The updated connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/collectors/slack/identity Stop being observed Removes the link between your Slack account and this organization. Messages you write stop being recorded immediately; hints already reported stay until they expire, and can be dismissed or deleted like any other. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Disconnected. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/collectors/slack/install Start connecting a Slack workspace Returns the URL to send the browser to. The workspace ends up bound to the organization that is active on this session. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackAuthorizeUrl` | application/json | Where to send the browser. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/collectors/slack/presence Start reporting when Slack shows you as active Its own round trip, because it is the only one that asks for `users:read` — permission to read your own presence. Somebody who only wants channel hints never grants it, and reads are only ever about the person who authorised, never anybody else in the workspace. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackAuthorizeUrl` | application/json | Where to send the browser. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/collectors/slack/presence Stop reporting when you are active in Slack Unlike the status, there is nothing to take down at Slack — presence is only ever read. The token is shared with the status and huddle features, so it is only forgotten and revoked once both of those are switched off too. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Switched off. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/collectors/slack/status Start showing a running timer in your Slack status Its own round trip, because it is the only one that asks for permission to write your Slack profile — `users.profile.set` accepts a user token only, and a bot token cannot stand in. Somebody who only wants hints never grants it. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackAuthorizeUrl` | application/json | Where to send the browser. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/collectors/slack/status How your status reads while a timer runs The template may use `{customer}`, `{project}` and `{task}`. The default names no customer: a Slack workspace routinely has guests and people working for someone else, and what you are billing is not always theirs to see. A field you omit is left as it is; send `null` to restore its default. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `statusTemplate` | `string \| null` | no | Placeholders `{customer}`, `{project}` and `{task}` are filled in. `null` restores the neutral default, which names no customer. | | `statusEmoji` | `string \| null` | no | `null` restores `:stopwatch:`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SlackConnection` | application/json | The updated connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/collectors/slack/status Stop showing timers in your Slack status Takes down a status that is still showing, forgets the user token and revokes it at Slack. A status you set yourself is left alone. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Switched off. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/collectors/usage-keys List your usage keys Your own usage keys for the active organization. `lastRequestAt` answers "is this agent exporting?". - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The keys.`{ data: UsageKey[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/collectors/usage-keys Create a usage key for a coding agent Creates the credential a coding agent exports its token usage with, bound to **you**, the **active organization**, one **source** (`claude-code` or `codex`) and a device label. The key is in the response and nowhere else. It can do one thing: `POST /api/v1/otlp/v1/logs`, for its own source. It cannot report hints or read anything. It is limited to 20,000 requests per clock hour on each API instance and does not expire; revoke it with `DELETE /collectors/usage-keys/{id}`. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `source` | `"claude-code" \| "codex"` | yes | The coding agent a usage key reports for. | | `device` | `string` | yes | The machine the agent runs on, as the key is labelled in the list. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CreatedUsageKey` | application/json | The new key, shown once. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/collectors/usage-keys/{id} Revoke a usage key The agent stops being able to export immediately. Usage it already exported stays. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The key was revoked. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/otlp/v1/logs Export coding-agent telemetry (OTLP/HTTP logs) The OpenTelemetry logs endpoint Claude Code and Codex export to, set up by `thymian collector install claude-code|codex`. OTLP/JSON only, uncompressed, at most 5 MiB. Authenticated by a usage key (`X-API-Key`), whose source decides which records are read: `claude_code.api_request` for Claude Code, a completed `codex.sse_event` for Codex. Every other record is discarded unread. Of a Claude Code record, only `vcs.repository.name` is read beyond session, model, time and token counts: it names a session no hint has reported, until one does. Answers `{}` whenever the body parses, including when records were dropped, so an exporter does not resend. A resent batch is counted once. Over the key’s limit of 20,000 requests per clock hour, counted on each API instance, the answer is 429 with `Retry-After` until the hour ends. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `resourceLogs` | `object[] \| null` | no | | | `resourceLogs.resource` | `object \| null` | no | | | `resourceLogs.scopeLogs` | `object[] \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `OtlpExportResponse` | application/json | Accepted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 413, 415, 422, 429. ## Events Live notifications that something changed, so a client can refresh instead of polling. Signals only — the change itself is read through the endpoints above. ### GET /api/v1/events Live change notifications (Server-Sent Events) A stream of notifications that something relevant to the authenticated client changed, so it can refresh without polling. **An organization- or user-scoped frame carries no domain data** — only `kind`, `organizationId` when applicable, and `audienceUserId`. Read the change through the ordinary endpoints afterwards. Frames are `time-entries.changed`, `hints.changed`, `invoices.changed`, `sessions.changed`, and `release.available`. `invoices.changed` reaches only members whose role may read invoices, and says that an invoice changed without a write through this API — Easybill reported news about its exported document — so refetch invoices. `sessions.changed` is addressed to the authenticated user rather than an organization and causes clients to refetch their Better Auth session inventory. `release.available` carries `{kind, clientType, version}` and is deployment-wide rather than organization-scoped — sent only to a connection that opened with `x-thymian-client: desktop`). Comment lines beginning with `:` are keep-alives and can be ignored. There is no `id:` and `Last-Event-ID` is not honoured: nothing is buffered for a disconnected client, so **refetch on every reconnect** rather than assuming continuity. The server closes the connection after 15 minutes so that membership is re-checked; reconnecting is expected and is not an error. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string` | text/event-stream | The event stream. Stays open until the client or the server closes it. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403. ## Access tokens Minting and revoking the scoped credentials of the public API. A session only — a token that could mint a token would survive its own revocation. ### GET /api/v1/access-tokens List your personal access tokens Your own tokens for the active organization, never anybody else’s — not even for an owner, because a token is a credential rather than organization data. `lastRequestAt` is what answers "is this thing still in use?" before revoking it. The token itself is not here and cannot be: only a hash is stored. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The tokens.`{ data: AccessToken[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/access-tokens Mint a personal access token Creates a credential bound to **you** and to the **active organization**, carrying exactly the scopes you name. The token is in the response and nowhere else — it is stored hashed, so losing it means minting another. A scope never widens what you may already do: the token’s authority is its scopes intersected with your role, so `thymian:reports.read` on a plain member’s token still answers 403. Send it as `Authorization: Bearer ` or `X-API-Key: `. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | How this token is labelled in the list, e.g. what uses it. | | `scopes` | `("thymian:read" \| "thymian:track" \| "thymian:timer" \| "thymian:master-data.read" \| "thymian:master-data.write" \| "thymian:project-members.read" \| "thymian:project-members.write" \| "thymian:project-rates.read" \| "thymian:project-rates.write" \| "thymian:time.read-own" \| "thymian:time.write-own" \| "thymian:time.read-others" \| "thymian:budgets.read" \| "thymian:invoice-drafts.write" \| "thymian:time-allowances.read" \| "thymian:time-allowances.write" \| "thymian:fixed-price.read" \| "thymian:fixed-price.write" \| "thymian:projects.write" \| "thymian:customers.write" \| "thymian:project-icons.write" \| "thymian:budgets.write" \| "thymian:invoices.read" \| "thymian:invoices.write" \| "thymian:billing.read" \| "thymian:billing-proposals.write" \| "thymian:billing-rules.write" \| "thymian:invoice-status.read" \| "thymian:organization.write" \| "thymian:reports.read" \| "thymian:hints.write" \| "thymian:hints.read" \| "thymian:absence-subjects.read" \| "thymian:persons.read" \| "thymian:planning.read" \| "thymian:planning.write")[]` | yes | What the token may do. Required and never defaulted: a token whose permissions nobody chose is the one nobody can reason about later. | | `expiresInDays` | `integer` | no | Optional lifetime in days. Omit for a token that does not expire — revoking it is then the only way it stops working. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CreatedAccessToken` | application/json | The new token, shown once. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/access-tokens/{id} Revoke a personal access token It stops working immediately. Anything it recorded stays — a time entry belongs to the person who tracked it, not to the credential that sent it. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The token was revoked. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## MCP A Model Context Protocol server, so an assistant can read projects and record time for the person who connected it. Authenticated by OAuth 2.1 rather than by a session, and scoped to the one organization chosen when the client was approved. ### POST /api/v1/mcp Model Context Protocol endpoint (Streamable HTTP) A Model Context Protocol server, so an assistant can look up projects and record time on behalf of the person who connected it. **Authentication is OAuth 2.1**, with Timjami as the authorization server. A client discovers it from `/.well-known/oauth-protected-resource`, or from the `WWW-Authenticate` header on a 401 here. PKCE is required. **The organization is not a parameter.** It is chosen by the person on the approval page and stored with the grant. Scopes: thymian:master-data.read, thymian:master-data.write, thymian:project-members.read, thymian:project-members.write, thymian:project-rates.read, thymian:project-rates.write, thymian:time.read-own, thymian:time.write-own, thymian:time.read-others, thymian:budgets.read, thymian:invoice-drafts.write, thymian:time-allowances.read, thymian:time-allowances.write, thymian:fixed-price.read, thymian:fixed-price.write, thymian:persons.read, thymian:budgets.write, thymian:billing.read, thymian:billing-proposals.write, thymian:billing-rules.write, thymian:invoice-status.read, thymian:invoices.read, thymian:reports.read, thymian:hints.write, thymian:hints.read, thymian:planning.read, thymian:planning.write. Tools: `list_customers`, `list_projects`, `list_tasks`, `create_customer`, `update_customer`, `set_customer_icon`, `remove_customer_icon`, `set_project_icon`, `remove_project_icon`, `create_project`, `update_project`, `create_task`, `update_task`, `list_persons`, `list_project_members`, `assign_project_member`, `remove_project_member`, `get_project_rates`, `set_project_rates`, `list_time_entries`, `list_team_time_entries`, `get_running_timer`, `summarize_time`, `start_timer`, `stop_timer`, `log_time`, `update_time_entry`, `remove_time_entry_reference`, `list_hints`, `report_hint`, `list_budgets`, `get_budget_consumption`, `create_budget`, `supersede_budgets`, `set_budget_adjustment`, `remove_budget_adjustment`, `list_billing_rules`, `get_billing_rule`, `list_billing_proposals`, `get_billing_proposal`, `dismiss_empty_billing_proposal`, `create_billing_rule`, `update_billing_rule_schedule`, `change_billing_rule_coverage`, `move_billing_allocation`, `list_invoices`, `get_invoice`, `preview_invoice`, `create_invoice`, `delete_invoice_draft`, `list_time_allowances`, `get_time_allowance`, `create_time_allowance`, `supersede_time_allowances`, `correct_time_allowance`, `delete_time_allowance`, `set_time_allowance_adjustment`, `remove_time_allowance_adjustment`, `list_fixed_price_agreements`, `get_fixed_price_agreement`, `create_fixed_price_agreement`, `rename_fixed_price_agreement`, `delete_fixed_price_agreement`, `list_fixed_price_items`, `get_fixed_price_item`, `create_fixed_price_item`, `change_fixed_price_item`, `delete_fixed_price_item`, `set_task_fixed_price_agreement`, `get_company_metrics`, `list_company_metric_months`, `list_company_metric_weeks`, `set_planning_context`, `get_planning_context`, `set_planning_allocation`, `refine_planning_allocation`, `get_planning_overview`, `get_work_history`. Tools unavailable because of role capability or live grant are hidden; a missing OAuth scope remains discoverable so the client can request step-up authorization. The transport is stateless and answers `application/json`; it opens no stream, so there are no server-initiated notifications. - Scope: none beyond authenticating - Credentials: carries its own credential **Request body (application/json)**: opaque — see the OpenAPI document **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | A JSON-RPC response. | **Problems** (RFC 9457 `application/problem+json`): 401, 403. ### GET /api/v1/mcp/consent Describe a pending MCP authorization request What the approval page shows. Everything is read from the signed authorization request the authorization server sent the page, and nothing unsigned is believed, which is why the page can be trusted to name the client. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `oauthQuery` | `string` | yes | The approval page’s `oauth_query` parameter: the authorization request, as the authorization server signed it. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `McpConsentRequest` | application/json | The pending request. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### POST /api/v1/mcp/consent Approve or deny an MCP authorization request Records which organization the client may act in and completes better-auth’s consent step. Answers the URI the browser must be sent to. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `oauthQuery` | `string` | yes | | | `accept` | `boolean` | yes | | | `organizationId` | `string` | no | | | `scopes` | `string[]` | no | | | `hintsReadEnabled` | `boolean` | no | | | `budgetWriteEnabled` | `boolean` | no | | | `moneyReadEnabled` | `boolean` | no | | | `billingWriteEnabled` | `boolean` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `McpConsentDecision` | application/json | Where to send the browser. | **Problems** (RFC 9457 `application/problem+json`): 401, 403, 404, 422. ### GET /api/v1/mcp/grants List your connected MCP clients Session only. Lists the concrete grants you approved, the scopes each token carries, and their independently withdrawable hint-read, budget-write, money-read and billing-write switches. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The grants, newest first.`{ data: McpGrant[] }` | **Problems** (RFC 9457 `application/problem+json`): 401. ### PATCH /api/v1/mcp/grants/{id} Change a live switch of a connected client Session only. Send one switch per request. Enabling budget writing requires current membership and the `budget.write` capability; enabling money reading, billing writes or hint reading requires current membership. Disabling is always available to the grant owner, even after membership was lost. No switch changes the scopes already carried by a token. Enabling applies to the organization the grant names when the request is read; if the client was re-approved for another organization in the meantime, the request conflicts rather than enabling anything. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)**: opaque — see the OpenAPI document **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `McpGrant` | application/json | The updated grant. | **Problems** (RFC 9457 `application/problem+json`): 401, 403, 404, 409. ## Absence Subjects ### GET /api/v1/absence-subjects List absence subjects Everybody in the active organization an absence may be recorded against — the durable, machine-readable roster a mapping integration such as Zynkey reads on a schedule, distinct from `organization/list-members`, which is for the app’s own interactive membership screens and carries no pagination or stability contract. **Scope.** Requires `thymian:absence-subjects.read` and nothing wider: a token minted for this alone cannot read customers, projects, tasks, time, or money. **Pagination.** Keyset, newest-membership-first is not the order — the order is join order, oldest first, which never reshuffles as people are renamed. `limit` is 1–100, default 50. Pass the previous page’s `nextCursor` back unchanged to continue; `null` means the page was the last one. The set is walked to completion without skipping or repeating a person as long as nobody new joins mid-walk — a join during a walk is placed by its own membership instant, so it can only ever appear ahead of where the walk already is. **Completeness.** Every subject in the organization is returned on every walk, active or not — `status` says `deactivated` for a banned account rather than the row disappearing, so an integration is never left believing somebody it once mapped stopped existing. **Fields.** Only what identity mapping needs: a stable id, a display name, an email where one exists, a lifecycle status, and a `kind` reserved for a future login-less subject. No role, no image, no organization settings. **Rate limiting.** Governed by the same per-credential API rate limit as every other `/api/v1` route; no endpoint-specific limit applies. - Scope: `thymian:absence-subjects.read` - Credentials: session, personal access token, no OAuth (personal access token only) **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `limit` | `integer` | no | At most 100 per page. | | `cursor` | `string` | no | The previous page's `nextCursor`, passed back unchanged. Names the row it left off on, so the set can be walked to completion without skipping or repeating anybody even while people join the organization. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | One page of absence subjects, in stable join order.`{ data: AbsenceSubject[], nextCursor: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Absences ### GET /api/v1/absences List absences Owners and admins see the whole organization's; a member sees only their own, whether or not they pass `personId`. Both ends of the window are inclusive, and an absence is returned when its span overlaps the window at all — a fortnight of leave belongs to every week it touches. `orderBy: 'fromDay'` (the default) is this unbounded window read, and `nextCursor` is always `null`. `orderBy: 'updatedAt'` is a sync order instead, ordering by when an absence was last written rather than by the days it covers: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `fromDay` | `string` | no | Inclusive. Omitted means unbounded. | | `untilDay` | `string` | no | Inclusive. Omitted means unbounded. | | `personId` | `string (uuid)` | no | Whose absences. A member may only name themselves; owners and admins may name anybody, and reading without it answers with the whole organization’s. | | `orderBy` | `"fromDay" \| "updatedAt"` | no | 'fromDay' (the default) is the ordinary window read — unbounded, oldest first, no `cursor`. 'updatedAt' is a sync order instead, ordering by when an absence was last written rather than by the days it covers: page from the top (no `cursor`), compare `updatedAtPrecise` (never `updatedAt`, which is millisecond-rounded), and reprocess every row equal to the last value already seen before stopping below it, to sync recent writes — an HR import or bulk correction touching several absences at once ties on `updatedAt` the same way a bulk time-entry edit does. | | `cursor` | `string` | no | Keyset cursor for `orderBy: 'updatedAt'`: pass the previous page's `nextCursor` back unchanged, together with the same `orderBy`. Not accepted with the default order, which is never paginated. | | `limit` | `integer` | no | Only meaningful with `orderBy: 'updatedAt'`; ignored by the default order. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AbsenceList` | application/json | The absences. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/absences Record an absence Leave, illness or a public holiday. Not tracked or billable time: an absence contributes to no total anywhere, it only changes what a day is expected to hold. `personId` defaults to the caller; naming somebody else needs owner or admin. A `fromDay` further back than the organization records time for answers **409** with `backdatingLimit` naming the day and the first day still open. `fromDay` decides the whole absence, `untilDay` included. Owners and admins (`absence.editLocked`, session only) may record it anyway and then **must say why** — `editReason`, else **422** with `reasonRequired`. The sentence is kept on the `created` revision and read at `GET /absences/{id}/revisions`. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Whose absence. Defaults to the caller; naming somebody else needs owner or admin. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | Inclusive. Equal to `fromDay` for a single day. | | `secondsPerDay` | `integer \| null` | no | Omit or send `null` for whole days. A number is an absolute duration per covered day — 14400 for half of an eight-hour day, 1800 for thirty minutes. | | `fractionOfDay` | `number` | no | A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`. | | `note` | `string \| null` | no | | | `source` | `string` | no | Defaults to `manual`. | | `externalId` | `string \| null` | no | | | `editReason` | `string` | no | Why an absence is being recorded behind the organization’s tracking deadline anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `Absence` | application/json | The recorded absence. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### PATCH /api/v1/absences/{id} Correct an absence A correction, not a move: neither the person nor the source can be changed, because either would make this row a different fact while keeping its id. Refused with **409** and `backdatingLimit` when the absence sits further back than the organization records time for **or** would be moved there — both, so an absence cannot be moved out of the frozen range either. Owners and admins (`absence.editLocked`, session only) may correct it anyway with an `editReason`, else **422** with `reasonRequired`; the sentence lands on the revision this write appends. A request that changes nothing is answered with the row. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | no | | | `fromDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `secondsPerDay` | `integer \| null` | no | | | `fractionOfDay` | `number` | no | A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`. | | `note` | `string \| null` | no | | | `editReason` | `string` | no | Why an absence that sits behind the organization’s tracking deadline — or would be moved there — is being changed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Absence` | application/json | The corrected absence. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/absences/{id} Remove an absence Deleted rather than archived: nothing is attributable to an absence, so a removed one leaves no figure behind needing an explanation — but the `deleted` revision outlives it, and is the last thing this absence’s history says. Refused with **409** and `backdatingLimit` when the absence is further back than the organization records time for: removing one gives a demand back exactly as recording it took one away. Owners and admins (`absence.editLocked`, session only) may remove it anyway and then must say why — `editReason`, a query parameter because a `DELETE` carries no body — else **422** with `reasonRequired`. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `editReason` | `string` | no | Why an absence behind the organization’s tracking deadline is being removed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The absence was removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/absences/{id}/revisions Read an absence's history Who changed this absence, when, and what stood there before — one row per write, oldest first (ADR-2026-08-26-14VZ4). The snapshot on each revision is the state the action **replaced**; for `created` it is the initial values, and the *current* state is always the live absence. Ordered oldest first, and `position` — not `recordedAt` — is that order: `recordedAt` is the writing transaction’s clock, so a transaction that begins first and writes second carries the earlier timestamp. Readable by the person the absence is about and by owners and admins (`absence.manageOthers`) — the same authority that decides whose absences somebody may see at all. The history outlives the absence, so a deleted one still answers here. **404** covers everything else without distinguishing: a history that is not yours, an absence older than the log, and an id that names nothing. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The absence's revisions, oldest first — `position` counts from 0 in that order.`{ data: AbsenceRevision[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/absences/external/{source}/{externalId} Import an absence Records or corrects the absence an external system knows by `externalId` — one call an importer may make **as often as it likes**. **201** when it recorded a new one, **200** when it found and wrote the existing one, and the body is the absence either way, so the caller learns the id it may have lost. The key is `(organization, source, externalId)`, the organization coming from the credential. That is what makes this safe to retry: a `POST /absences` whose response never arrived cannot be sent again — the second send is a 409 — while this one finds the row the first send wrote and answers with the same id. **The whole absence, not a patch.** Every field this does not carry is cleared, `secondsPerDay` and `note` included, because a mirror that can only add fields drifts from its source the first time somebody empties one over there. A request that would change nothing writes nothing and appends no revision, so polling does not fill the history with rows saying nobody changed anything. `userId` may move an absence to another person here, unlike `PATCH /absences/{id}`: the external id names one absence in the source system, which already knows whose it is. Naming anybody but yourself — and touching an absence that is already somebody else’s — needs owner or admin. Somebody who has left the organization can no longer be **newly** named — a fresh absence for them, or a move onto them, is **422** — but the absences they already have keep importing, corrections included. An absence outlives its subject’s membership, so refusing those would leave a source that still sends them stuck on a 422 nothing can clear. Behind the organization’s tracking deadline this answers **409** with `backdatingLimit` exactly as recording one by hand does, judged on `fromDay` — the day the absence sits on today **and** the day this write would move it to. Owners and admins holding `absence.editLocked` (session only) may write through it with an `editReason`, else **422** with `reasonRequired`; the sentence lands on the revision this write appends. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `source` | `string` | yes | The importing system’s slug. `manual` is what a person typing an absence in leaves behind, so an importer names itself: `personio`. | | `externalId` | `string` | yes | That system’s own id for this absence. Percent-encode it if it is not URL-safe. | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Whose absence. Defaults to the caller; naming somebody else — including moving an existing absence to somebody else — needs owner or admin. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | Inclusive. Equal to `fromDay` for a single day. | | `secondsPerDay` | `integer \| null` | no | Omit or send `null` for whole days. Omitting it on an absence that had one **clears** it: this is the whole absence, not a patch. | | `fractionOfDay` | `number` | no | A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`. | | `note` | `string \| null` | no | Omitting it clears an existing note, for the reason `secondsPerDay` gives. | | `editReason` | `string` | no | Why an absence is being imported behind the organization’s tracking deadline anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Absence` | application/json | The absence this source already had, as it now stands. | | 201 | `Absence` | application/json | The absence this source did not have yet. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### GET /api/v1/absences/revisions What changed in one month One month of the absence audit log — every `created`, `updated` and `deleted` write recorded in it (ADR-2026-08-26-14VZ4’s log, ADR-2026-08-26-20V59). „Protokoll" reads this beside `GET /time-entries/revisions` and shows the two as one chronology. The month bounds **when a change was recorded**, resolved in the organization’s own timezone — never the days the absence covers. Inside the month the answer is one **page**: newest first, `limit` rows, `nextCursor` where more follow — pass it back unchanged together with the same `month` and `userId`. **Merging this with `GET /time-entries/revisions` is only honest down to the older of the two frontiers.** Each log pages on its own, so show the merged chronology down to the *newer* of the two boundaries you hold and keep the rest until the other log has been paged that far. Below it one log is complete and the other is not, and a chronology missing every change of one kind looks like a month in which none happened. **The order is the answer’s own.** Rows arrive grouped by absence, oldest write first inside each group — the order the writes happened, which is what makes a change derivable: a revision holds the state its write *replaced*, so what it changed is the step to the next snapshot of the same absence. Sort by `recordedAt` for display and never for pairing. `heads` closes that derivation at the edge of the **page**: for each absence, the state its newest write on this page produced — the next revision’s snapshot where it was changed again later (which, for an absence seen on the page before, is that page’s oldest row for it), the live absence where it was not, and **nothing at all** where it was deleted, which needs no such state. So each page derives on its own and a client merges the results. Whose changes: your own by default, and naming a *specific other person’s* `userId` requires the `owner` or `admin` role (`absence.manageOthers`). An owner or admin who names nobody reads the whole organization’s; a member who names nobody reads their own. An empty month answers `200` with empty lists. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `personId` | `string (uuid)` | no | Whose changes. A member may only name themselves and reading without it answers with their own; owners and admins may name anybody, and reading without it answers with the whole organization’s (`absence.manageOthers`). | | `limit` | `integer` | no | | | `cursor` | `string` | no | Keyset cursor **within the month**: pass the previous page's `nextCursor` back unchanged, together with the same `month` and `personId`. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | One page of the month’s revisions, grouped by absence in write order, plus one head per absence that has one.`{ data: AbsenceLogEntry[], heads: AbsenceLogHead[], nextCursor: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Access ### POST /api/v1/access-requests Request access to Timjami Public and unauthenticated. Always answers 202 once the body validates, whether or not the address is already known — a stranger probing for known addresses learns nothing from the response. A repeated request while one is still pending updates it rather than filing a second one. - Scope: none beyond authenticating - Credentials: carries its own credential **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `email` | `string (email)` | yes | | | `company` | `string` | yes | | | `message` | `string` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 202 | `AccessRequestAccepted` | application/json | The request was recorded. | **Problems** (RFC 9457 `application/problem+json`): 403, 422, 429. ### POST /api/v1/access/invite-codes/check Check whether an invite code is usable Public and unauthenticated: whether a code is unknown, expired, revoked, or exhausted, it answers the same 422 either way. A usable code sets a short-lived, unsigned cookie the sign-up gate re-validates against the database on every use. - Scope: none beyond authenticating - Credentials: carries its own credential **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `code` | `string` | yes | An invite code, in any casing or spacing — normalized server-side. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `InviteCodeCheckResult` | application/json | The code is usable. | **Problems** (RFC 9457 `application/problem+json`): 403, 422, 429. ## Admin ### GET /api/v1/admin/access-requests The access-request queue (operators only) Every access request, newest pending first, then decided requests newest first (`access.listRequests()`’s own order). `inviteCode` names the code an approval issued, and stays `null` while a request is pending or was declined. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AdminAccessRequestList` | application/json | The access-request queue. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### POST /api/v1/admin/access-requests/{id}/approve Approve a pending access request (operators only) Issues a single-use invite code bound to the requester’s address and emails it to them, then returns the code so the operator can hand it over even if the email failed (`emailSent: false`). Refuses a request that is no longer pending, including a second approval of the same request. The body names the features the organization founded with the code starts with (ADR-2026-09-30-17KCB); it is required, and `[]` issues a code without any. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `features` | `OperatorFeature[]` | yes | The features an organization founded with the code starts with; `[]` for none. Duplicates are stored once. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ApproveAccessRequestResult` | application/json | The code just issued. | **Problems** (RFC 9457 `application/problem+json`): 401, 404, 409, 422. ### POST /api/v1/admin/access-requests/{id}/decline Decline a pending access request (operators only) Refuses a request that is no longer pending, including a second decline. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The request was declined. | **Problems** (RFC 9457 `application/problem+json`): 401, 404, 409, 422. ### GET /api/v1/admin/app-installations Recently reported app versions (Suora staff only) Current version sightings grouped by app and version. "Recent" means seen within 30 days; silence never proves uninstall. Reachable only by a platform operator and recorded in the access log. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformAppVersions` | application/json | The current app-version distribution. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### GET /api/v1/admin/audit-log Who opened the admin view, and what they read (Suora staff only) The platform admin access log itself — every call any operator has made against this admin view, newest first, capped at the most recent 200 rows. Reachable only by a person granted `platform_operator` (ADR-2026-08-18-18HXT); reading this log is itself recorded in it, like every other admin read. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformAccessLog` | application/json | The most recent access log rows. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### GET /api/v1/admin/invite-codes Every invite code, with its redemptions (operators only) `status` is derived, never stored: `revoked` once `revokedAt` is set, else `used-up` once `uses` reaches `maxUses`, else `expired` once `expiresAt` has passed, else `active`. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AdminInviteCodeList` | application/json | Every invite code, newest first. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### POST /api/v1/admin/invite-codes Mint an invite code (operators only) Both single- and multi-use codes: `maxUses: 1` for a single-use code, higher for a multi-use one shared with several strangers (ADR-2026-09-24-11NJJ). `email`, when given, binds the code to one address. `expiresAt` must be in the future. `features` names the features the organization the code founds starts with (ADR-2026-09-30-17KCB); it is required, and `[]` issues a code without any. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `features` | `OperatorFeature[]` | yes | The features an organization founded with the code starts with; `[]` for none. Duplicates are stored once. | | `label` | `string` | yes | | | `email` | `string (email)` | no | | | `maxUses` | `integer` | yes | | | `expiresAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `AdminInviteCode` | application/json | The invite code just minted. | **Problems** (RFC 9457 `application/problem+json`): 401, 404, 422. ### POST /api/v1/admin/invite-codes/{id}/revoke Revoke an invite code (operators only) A code already revoked, or unknown, answers 404 either way. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The code was revoked. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### GET /api/v1/admin/organizations Every organization on this deployment (Suora staff only) Suora's own cross-organization admin view — metadata only, never money and never a customer, project or task belonging to any organization. Reachable only by a person granted `platform_operator` (ADR-2026-08-18-18HXT); every call is recorded in the platform admin access log. An organization left out of the platform analytics is still listed, marked with `excludedFromAnalytics` (ADR-2026-11-02-11QNV). - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformOrganizations` | application/json | Every organization, newest first. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### GET /api/v1/admin/organizations/{id} One organization's detail (Suora staff only) The same metadata as the list, plus this organization's roster — accepted members and still-pending invitations alike, each row saying which it is — and the features it switched off for itself, without who or when (ADR-2026-10-31-12YXA). Still never money and never a customer, project or task belonging to the organization. Reachable only by a person granted `platform_operator` (ADR-2026-08-18-18HXT); every call is recorded in the platform admin access log. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformOrganizationDetail` | application/json | The organization and its members. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### DELETE /api/v1/admin/organizations/{id} Delete an organization without members (operators only) Requires a fresh, session-bound, user-verified operator passkey assertion and the organization slug as confirmation. Refuses organizations with members, including concurrent joins. Permanently deletes remaining organization data and invitations. Every attempt is rate limited; successful deletion is audited atomically. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `proof` | `object` | yes | | | `proof.challengeId` | `string (uuid)` | yes | | | `proof.response` | `object` | yes | | | `confirmation` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Organization and remaining data deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422, 429. ### PUT /api/v1/admin/organizations/{id}/analytics-exclusion Leave one organization out of the platform analytics (operators only) From now on the organization counts neither on the usage page nor in the platform figures: not its entries, its members’ accounts and installations, its MCP grants or its integrations. Nothing of the organization changes and its members notice nothing (ADR-2026-11-02-11QNV). Idempotent. Every call is recorded in the platform admin access log in the same transaction. Reachable only by a person granted `platform_operator`. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The organization is left out of the analytics. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/admin/organizations/{id}/analytics-exclusion Count one organization in the platform analytics again (operators only) Idempotent. The organization counts again from the next read, its past included, since an exclusion only filters reads and deletes nothing (ADR-2026-11-02-11QNV). Every call is recorded in the platform admin access log in the same transaction. Reachable only by a person granted `platform_operator`. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The organization counts in the analytics. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/admin/organizations/{id}/features/{feature} Switch a feature on for one organization (operators only) Idempotent: switching on a feature that is already on answers the same. Every call is recorded in the platform admin access log in the same transaction (ADR-2026-09-29-17JKR). Reachable only by a person granted `platform_operator`. A feature that requires another (`jami` requires `tour`) is stored without it, but takes effect for the organization only once its prerequisite is on as well (ADR-2026-09-30-17KCB). Switching `tour` on when it was off offers the tour to the organization’s members who have no progress in it yet, in the same transaction: they already use the application, so they are asked rather than walked through it (ADR-2026-09-28-12X97). - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `feature` | `OperatorFeature` | yes | A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The feature is on for this organization. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/admin/organizations/{id}/features/{feature} Switch a feature off for one organization (operators only) Idempotent. Leaves the data behind the feature in place, so switching it on again restores access to all of it. Every call is recorded in the platform admin access log in the same transaction (ADR-2026-09-29-17JKR). Reachable only by a person granted `platform_operator`. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `feature` | `OperatorFeature` | yes | A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The feature is off for this organization. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/admin/organizations/{id}/users/{userId} Delete an organization member’s login account (operators only) Requires a fresh, session-bound, user-verified operator passkey assertion and the target email as confirmation. Preserves person and time history, revokes account credentials and sessions. Self-deletion, operator accounts and audit actors are protected. Every attempt is rate limited; successful deletion is audited atomically. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `userId` | `string` | yes | better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID. | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `proof` | `object` | yes | | | `proof.challengeId` | `string (uuid)` | yes | | | `proof.response` | `object` | yes | | | `confirmation` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Account deleted; historical person data retained. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422, 429. ### PUT /api/v1/admin/organizations/{id}/users/{userId}/features/{feature} Switch a feature on for one member of an organization (operators only) Adds to what the organization has: it changes nothing for anybody else, and neither this grant nor the organization’s can take the other away (ADR-2026-11-03-12TFG). It only lifts the feature gate; the member’s role and a credential’s scopes apply as before. Idempotent, and every call is recorded in the platform admin access log in the same transaction. Answers 404 unless the account is an accepted member of that organization. Reachable only by a person granted `platform_operator`. A feature that requires another (`jami` requires `tour`) is stored without it, and takes effect for the member once its prerequisite is on for them, whichever level that comes from. Switching `tour` on for the member when it was off offers them the tour, unless they already have progress in it, in the same transaction. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `userId` | `string` | yes | better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID. | | `feature` | `OperatorFeature` | yes | A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The feature is on for this member. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/admin/organizations/{id}/users/{userId}/features/{feature} Switch a member’s own grant of a feature off (operators only) Idempotent. Takes away this member’s own grant and nothing else: a feature the organization has stays on for them, and the data behind it stays in place (ADR-2026-11-03-12TFG). Every call is recorded in the platform admin access log in the same transaction. Answers 404 unless the account is an accepted member of that organization. Reachable only by a person granted `platform_operator`. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `userId` | `string` | yes | better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID. | | `feature` | `OperatorFeature` | yes | A feature a platform operator switches on per organization (ADR-2026-09-29-17JKR). Features the organization switches itself are not among them (ADR-2026-10-25-09PNG). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | This member’s own grant is gone. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/admin/releases Every product’s release/deploy status (Suora staff only) One row per product — web, iOS, CLI, both browser extension shapes, the desktop app and the Raycast extension. iOS and Raycast have no GitHub Release, so their "version"/"tag"/"releaseUrl" are read off the repository's own tag list instead, gated on `GITHUB_ACTIONS_TOKEN` alone — unlike `cli`/`desktop`/`extension`/`safariExtension`, whose `version`/`tag`/`releaseUrl` come from an actual GitHub Release and are `null` without `GITHUB_DOWNLOAD_TOKEN` instead. `web` carries no version at all — its currently deployed commit is read client-side from the web bundle's own build-time SHA instead. Either way, the same field is `null` when GitHub answers that lookup with an error; `releaseWorkflow` is `null` the same two ways for `GITHUB_ACTIONS_TOKEN` — independently, per field, never failing the whole route or hiding a field the other token can still answer (ADR-2026-08-24-11FS5). Each product also carries `activeReleaseWorkflows` (`activeDeployWorkflows` for web) — every run of that workflow still queued or in progress, since `main` merges close together can leave more than one deploy building at once; always `[]`, never `null`, in both degrade cases above. `web` additionally carries `pendingCiWorkflows`: `deploy.yml` only triggers once `CI` finishes, so a commit whose tests are still running has no `deploy.yml` run yet even though it will deploy automatically the moment CI goes green — this is every such `CI` run still queued or in progress for a push to `main` (never a pull request), and `blockingCiWorkflow`: the `CI` run that made the latest `deploy.yml` run skip, since that run itself never says why. Reachable only by `platform_operator` (ADR-2026-08-18-18HXT); every call is logged. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformReleases` | application/json | Every product’s current release/deploy status. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### GET /api/v1/admin/usage Feature usage per organization (Suora staff only) Per organization and usage area: `activity` areas count rows of the last 30 days, `setup` areas the configuration live now, `held` areas the rows held now. Counts per organization only, never a person and never money; an area whose feature is not in effect is listed in `notInEffect`. The summary in `areas` counts an organization as using an area with activity in the window, with a dated area (an app, a calendar or Harvest connection, planning, hints) seen or changed in it, or with any other setup area configured now. An organization an operator excluded from the analytics is not listed and counts in no summary (ADR-2026-11-02-11QNV). Reachable only by a platform operator and recorded in the access log. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformUsage` | application/json | Every organization, most recent time entry first. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ### POST /api/v1/admin/users/{userId}/impersonation-sessions Open a short-lived support session (Suora staff only) Requires a fresh, session-bound, user-verified operator passkey assertion for this target. Creates an independent Better Auth impersonation session without changing the operator session cookie. The returned token is valid for at most one hour and must be sent as a bearer token. It is never repeated and the response is not cacheable. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `userId` | `string` | yes | better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID. | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `proof` | `object` | yes | | | `proof.challengeId` | `string (uuid)` | yes | | | `proof.response` | `object` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `ImpersonationSession` | application/json | The new support session. | **Problems** (RFC 9457 `application/problem+json`): 401, 403, 404, 422. ### GET /api/v1/admin/users/{userId}/push-logs One person's recent push delivery logs (Suora staff only) A bounded, newest-first projection of this person’s structured APNs events from Loki. This is deliberately not a general LogQL proxy: the server fixes the stream selector, user filter, provider, time ranges and result limit. Reachable only by a person granted `platform_operator`; every call is recorded in the platform admin access log. See ADR-2026-08-21-075DF. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `userId` | `string` | yes | better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID. | **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `range` | `"24h" \| "7d" \| "30d"` | no | How far back Loki should be queried. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformPushLogs` | application/json | Recent structured APNs events, newest first. | **Problems** (RFC 9457 `application/problem+json`): 401, 404, 503. ### GET /api/v1/admin/users/{userId}/push-tokens One person's currently registered push tokens (Suora staff only) Every visible iOS notification token, widget/complication push token, Live Activity push-to-start token and Live Activity update token currently registered for this person — read from the same per-user repositories `apps/api` already calls to send a push, across every organization a stale token might still name. A forgotten token stops existing rather than being marked inactive, so every row here is, by construction, currently registered. The related delivery history is exposed separately from Loki — see ADR-2026-08-21-075DF. Reachable only by a person granted `platform_operator` (ADR-2026-08-18-18HXT); every call is recorded in the platform admin access log. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `userId` | `string` | yes | better-auth account id — 32 characters of `[A-Za-z0-9]`, not a UUID. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlatformPushTokens` | application/json | This person's currently registered tokens. | **Problems** (RFC 9457 `application/problem+json`): 401, 404. ## Diagnostics ### POST /api/v1/app-errors Report a desktop, CLI or Raycast error Write-only diagnostics from the Timjami desktop app, command-line tool and Raycast extension. An optional bearer session token or OAuth access token attributes the report to its account; without one, or with any other credential, the report is accepted anonymously. No organization is inferred and no Origin is required. Accepts at most 8 KiB, 30 reports per minute per account and 120 anonymous reports per minute per API process. Client fields and installation headers are untrusted; errorId is independent of the server-generated requestId. Free-form messages, URLs, absolute paths and application data are not accepted. - Scope: none beyond authenticating - Credentials: carries its own credential **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `errorId` | `string (uuid)` | yes | | | `source` | `"panic" \| "webview" \| "upload" \| "collector" \| "api" \| "command"` | yes | | | `kind` | `"Panic" \| "UnexpectedStatus" \| "InvalidResponse" \| "Refused" \| "UploadFailing" \| "UnreadableInput" \| "UnreadableLocalState" \| "Error" \| "TypeError" \| "ReferenceError" \| "SyntaxError" \| "RangeError" \| "URIError" \| "EvalError" \| "AggregateError" \| "UnknownError"` | yes | | | `status` | `integer` | no | The HTTP status; only for api, upload and collector reports. | | `operation` | `string` | no | | | `frames` | `string[]` | yes | | | `build` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Report logged; requestId also appears in X-Request-Id.`{ errorId: string (uuid), requestId: string (uuid) }` | **Problems** (RFC 9457 `application/problem+json`): 413, 422, 429. ### POST /api/v1/client-errors Report a browser error Write-only, unauthenticated diagnostics for configured web origins, including before sign-in. No account or organization is inferred. Accepts at most 8 KiB and 120 requests per minute per API process. Client fields are untrusted; errorId is independent of the server-generated requestId. Free-form messages, URLs, and application data are not accepted. - Scope: none beyond authenticating - Credentials: carries its own credential **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `errorId` | `string (uuid)` | yes | | | `kind` | `"react" \| "route" \| "error" \| "unhandledrejection"` | yes | | | `name` | `"Error" \| "TypeError" \| "ReferenceError" \| "SyntaxError" \| "RangeError" \| "URIError" \| "EvalError" \| "AggregateError" \| "UnknownError"` | yes | | | `frames` | `string[]` | yes | | | `route` | `string` | yes | | | `build` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Report logged; requestId also appears in X-Request-Id.`{ errorId: string (uuid), requestId: string (uuid) }` | **Problems** (RFC 9457 `application/problem+json`): 403, 413, 422, 429. ## App installations ### POST /api/v1/app-installations/check-in Report the running version of this app installation Deprecated: only older versions of the Timjami apps call this. It is removed once none of them are in use any more. Records one latest sighting for the authenticated account and pseudonymous installation id. The server never accepts a user or organization id from the client. A successful response says only that the sighting was stored, not that the app is known to remain installed. See ADR-2026-08-24-15MW4. Besides a session, any OAuth or personal access token may check in, whatever its scopes. See ADR-2026-09-18-10DRH. - Scope: none beyond authenticating - Credentials: session, personal access token, OAuth - **Deprecated.** **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | | `app` | `"desktop" \| "cli" \| "ios" \| "browser-extension" \| "raycast"` | yes | | | `version` | `string` | yes | | | `platform` | `string` | yes | | | `architecture` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The latest installation sighting was stored. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Authentication ### GET /api/v1/auth/providers Available interactive sign-in providers - Scope: none beyond authenticating - Credentials: carries its own credential **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AuthProviders` | application/json | Enabled providers. | ## Avatars ### GET /api/v1/avatars/{token} Serve a person's avatar Returns the image bytes for a signed token, as found in `user.avatar.url`. Deliberately not authenticated with a session or a bearer token, for the same reason a customer or project icon is not — see `GET /icons/{token}`. Do not build these URLs; read them from `/me` or the organization member list. Do not store them either — a stale one answers 404, which is the signal to refetch. An image stored as WebP is sent as WebP only when `Accept` names `image/webp` explicitly; a wildcard such as `*/*` gets it re-encoded as PNG, because some clients (watchOS) send only wildcards and cannot decode WebP. A stored WebP that cannot be re-encoded is sent as WebP regardless of `Accept`. An image stored as PNG is always sent as PNG. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `token` | `string` | yes | Opaque signed token from `user.avatar.url`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | image/webp, image/png | The image. | | 304 | — | — | Not modified — the client already has these bytes. | **Problems** (RFC 9457 `application/problem+json`): 404. ## Billing proposals ### GET /api/v1/billing-proposals List billing proposals Customer/rule, period, due/overdue information and state — the entry point "Abzurechnen" links from. `openOnly=true` excludes complete proposals; a filter for "Nichts abzurechnen" is a read-time state check on the client. - Scope: `thymian:billing.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `billingRuleId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `openOnly` | `string` | no | Only proposals not yet complete. Defaults to every proposal. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The proposals.`{ data: BillingProposalListItem[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/billing-proposals/{id} One proposal, with its allocations - Scope: `thymian:billing.read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingProposalDetail` | application/json | The proposal. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/billing-proposals/{id}/allocations/{allocationId}/move Move or defer a service portion to another open proposal of the same rule A `reason` marks it a deferral (visible, and the target’s due date now governs reminders); omitting it is a plain correction. Refuses 409 when the portion is already inside a stored invoice draft — delete the draft first — or already settled, and when it left this proposal after it was read: reconciliation released it because its entry, agreement or item changed, or someone moved it — read the proposal again. No cross-rule reassignment. - Scope: `thymian:billing-proposals.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `allocationId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `toProposalId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `reason` | `string \| null` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Moved. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/billing-proposals/{id}/dismiss Confirm an empty proposal needs no billing For a proposal with no allocations at all — a period audited and found genuinely empty. Runs the reconciliation pass first, so an hour booked since the last background pass counts. Refused 409 once the proposal has any allocation; a later service reopens it the same way it would reopen any other completed proposal. - Scope: `thymian:billing-proposals.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingProposalDetail` | application/json | The proposal. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/billing-proposals/{id}/invoices Create the invoice for this proposal’s currently open allocations The precise allocation mask, never a wider date range — services other proposals own are never included. Runs the reconciliation pass first, so hours booked or corrected since the last background pass are included. Links the created invoice back to the proposal, which may link several over time (partial and supplementary billing). - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `notes` | `string \| null` | no | | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | no | Overrides the proposal’s own default grouping for this one invoice. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `object` | application/json | The invoice.`{ id: string (uuid) }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/billing-proposals/{id}/invoices/preview Preview the invoice this proposal’s open allocations would produce Creates no invoice and claims nothing, but first runs the same reconciliation pass the background worker runs every five minutes — for the whole organization — so an hour booked or corrected since the last pass is allocated and can reappear, move or reopen proposals. Shares the exact code path `POST .../invoices` uses to create one. `period` answers for the proposal’s own period what the draft cannot, since the draft reads with no lower bound: how far the projects it bills are closed against the last day it reads, the not-billable time booked on them, and other projects of the customer with open billable hours that no rule covers. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ProposalInvoicePreview` | application/json | The preview. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/billing-proposals/{id}/services The people, tasks, days and notes behind a proposal’s service portions One row per allocation of `GET /billing-proposals/{id}`, joined by `allocationId`: whose time it is, on which task, when it started, what was noted on it, and which invoice settled it or which draft already claims it. For a fixed-price item, `fixedPriceItem` says what it bills — description, kind, amount in its own currency and days — and `personName` and `taskName` are the people sharing it and its agreement. Session-only — a colleague’s notes and an invoice reference are neither state, period nor project. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The services.`{ data: BillingAllocationService[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Billing rules ### GET /api/v1/billing-rules List recurring billing rules - Scope: `thymian:billing.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `status` | `"active" \| "paused"` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The rules.`{ data: BillingRuleListItem[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/billing-rules Create a recurring billing rule No overlapping active rule may already cover a named project, or the customer dynamically, for an overlapping period — answers 409. Validated under a per-customer lock, so two concurrent creates cannot both succeed. Older services the rule takes on normally get their proposals in this request. If that step fails, the answer is still 201 with the rule saved and `hasProposals` false, and the next reconciliation pass, within minutes, makes the proposals. So `hasProposals: false` says that no proposal exists yet, neither that nothing is open nor that the rule will stay deletable. - Scope: `thymian:billing-rules.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | The customer this rule bills. | | `name` | `string` | yes | A human-readable name for the rule. | | `projectMode` | `"selected" \| "all"` | yes | `selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later. | | `projectIds` | `string (uuid)[]` | yes | Required, non-empty, when `projectMode` is `selected`; must be empty for `all`. | | `recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `firstPeriodStart` | `string` | yes | Normalized to a quarter start when `recurrence` is `quarterly`. | | `historyMode` | `"separate-periods" \| "collect-history" \| "handle-later"` | yes | The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month. | | `catchUpThroughPeriod` | `string \| null` | no | Only meaningful, and required, when `historyMode` is `collect-history`. | | `responsiblePersonIds` | `string (uuid)[]` | yes | The people who receive due/escalation email for this rule. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `BillingRule` | application/json | The rule. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/billing-rules/{id} One billing rule - Scope: `thymian:billing.read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingRule` | application/json | The rule. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/billing-rules/{id} Delete a rule that has not produced a proposal yet Removes the rule with its project coverage and its responsible people. Once any proposal exists — periodic, catch-up or historical — the rule is part of what was billed and answers 409; pause it instead. A periodic proposal is generated only once its period has ended, so a rule whose first period is the current one stays deletable throughout that period, unless its setup collects older outstanding hours. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The rule was deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/billing-rules/{id}/coverage Change which projects a rule covers, from a future period Closes the rule’s current coverage the month before `effectiveFromPeriod` and opens the new one from it. Existing proposals before the boundary keep their prior coverage (section 6). - Scope: `thymian:billing-rules.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `projectMode` | `"selected" \| "all"` | yes | `selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later. | | `projectIds` | `string (uuid)[]` | yes | Required, non-empty, when `projectMode` is `selected`; must be empty for `all`. | | `effectiveFromPeriod` | `string` | yes | A future period. Existing proposals before it keep their prior coverage. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingRule` | application/json | The rule. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/billing-rules/{id}/pause Pause a rule Stops new periodic proposal generation. Existing proposals stay visible and keep their reminders; historical reconciliation for late services still runs. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingRule` | application/json | The rule. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/billing-rules/{id}/project-addition/candidates List the projects that can still join the rule The customer’s visible, unarchived projects with a gap in their coverage this rule can fill, each with the months it can join over — the same window the preview and the join use. An archived project with open hours joins from „Offen“ instead. ADR-2026-10-07-119RC. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `JoinableProjectList` | application/json | The projects, by name. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/billing-rules/{id}/project-addition/preview Preview a project joining the rule from a past month Writes nothing. Groups the project’s open hours from `fromMonth` on by the period that would bill them. Each ended period is priced as the draft it would get — everything still open on its proposal plus the project’s hours — by the same computation a proposal’s invoice runs: organization-wide, rounded once per line, provisional until invoiced. The running period is priced on the project’s hours alone. The project joins over one gap in its coverage (`earliestMonth` … `latestMonth`); a `fromMonth` outside it, or more than 24 periods, answers 422. ADR-2026-10-07-119RC. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `fromMonth` | `string` | no | The month the project joins from. Omitted, the preview starts at `suggestedFromMonth`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ProjectAdditionPreview` | application/json | The preview. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/billing-rules/{id}/project-additions Add a project to an active rule from a past, current or future month Fills a gap in the project’s coverage: the rule covers it from `fromMonth` to the gap’s end, and no existing coverage, proposal or invoice changes. The project’s open hours are allocated to their periods’ proposals at once, reopening a completed one. With `createInvoices`, every ended period that now holds them gets one draft invoice, billing everything open on its proposal; a period whose draft would bill nothing is listed in `skippedProposalIds` instead, and the join stands. Refused before anything is written for a paused or all-projects rule, a project of another customer, a project with no free month, a month outside the gap, or more than 24 periods (ADR-2026-10-07-119RC). - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `fromMonth` | `string` | yes | The month the project joins from: a past, the current, or a future month between the preview’s `earliestMonth` and `latestMonth`. | | `createInvoices` | `boolean` | yes | Also create one draft invoice per ended period that now holds the project’s hours, each billing everything open on that period’s proposal. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `ProjectAdditionResult` | application/json | The rule, its affected proposals and drafts. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/billing-rules/{id}/resume Resume a paused rule - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `restartFromPeriod` | `string \| null` | no | A chosen period to resume from, becoming the rule’s new generation anchor. Omitted resumes from where it left off. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingRule` | application/json | The rule. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/billing-rules/{id}/schedule Change a rule’s live fields Name, due day, selection mode, default grouping and responsible people — takes effect immediately for proposals not yet generated. Recurrence and project coverage instead go through `PUT /billing-rules/{id}/coverage`, effective from a chosen future period. - Scope: `thymian:billing-rules.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | A human-readable name for the rule. | | `dueDayOfMonth` | `integer` | no | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `selectionMode` | `"period-only" \| "cumulative"` | no | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | no | The default invoice grouping a proposal generated under this rule freezes. | | `responsiblePersonIds` | `string (uuid)[]` | no | The people who receive due/escalation email for this rule. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingRule` | application/json | The rule. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/billing-rules/preview-history Preview outstanding historical periods before a rule’s first period Section 5’s setup step: which of the rule-to-be’s own periods, before `firstPeriodStart`, hold something a history mode would take on: open time, allowance months or completed fixed-price items, in months no other rule owns. Prices nothing — only which periods have something in them. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectIds` | `string` | no | Comma-separated project ids. Empty means every project of the customer. | | `recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `firstPeriodStart` | `string` | yes | A calendar month, `YYYY-MM`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The periods.`{ data: HistoricalPeriodPreview[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/billing-rules/preview-periods Preview a rule-to-be’s periods from its first one on Writes nothing. Every ended period (`ready`, a proposal once the rule is saved if `proposes` says so) and the running one are priced by the same computation a proposal’s invoice runs, over that period’s own months: organization-wide, rounded once per line, provisional until invoiced. Upcoming periods carry a due day and never an amount. With `historyMode`, `history` says what becomes of the hours before the first period. More than 24 ended periods answers 422. ADR-2026-09-18-13VXS. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectIds` | `string` | no | Comma-separated project ids. Empty means every project of the customer. | | `recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `firstPeriodStart` | `string` | yes | A calendar month, `YYYY-MM`. | | `dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `historyMode` | `"separate-periods" \| "collect-history" \| "handle-later"` | no | The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month. | | `catchUpThroughPeriod` | `string` | no | The last period the catch-up proposal reaches, for `collect-history`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BillingRulePeriodPreview` | application/json | The periods. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/billing-rules/preview-planned Planned hours for a rule-to-be’s running and upcoming periods Staff plans, never recorded time and never priced. A week counts in the period holding its Thursday. Requires planning authority. ADR-2026-09-18-13VXS. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectIds` | `string` | no | Comma-separated project ids. Empty means every project of the customer. | | `recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `firstPeriodStart` | `string` | yes | A calendar month, `YYYY-MM`. | | `dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The planned hours per period.`{ data: PlannedPreviewPeriod[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Break periods ### GET /api/v1/break-dismissals List your dismissed break questions Stretches inside an entry that nothing observed and that you said were work — the dismissed „Pause …?“ question on the day. Always your own: the question is never asked on somebody else’s day. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `until` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The dismissals.`{ data: BreakDismissal[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/break-dismissals Dismiss a break question Records that a stretch nothing observed was work, so the day stops asking whether it was a break. It changes no entry and no total. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `BreakDismissal` | application/json | The recorded dismissal. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/break-periods List explicit breaks Defaults to the caller. Naming a *specific other person's* `personId` requires the `owner` or `admin` role — the same gate `GET /time-entries` applies, because a break is the answer to a gap that role can already see. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `until` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `personId` | `string (uuid)` | no | Whose breaks. Defaults to the caller; naming a *specific other person* requires the `owner` or `admin` role. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The breaks.`{ data: BreakPeriod[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/break-periods Record an explicit break A break answers a gap in the UI. It is not tracked or billable time. - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `BreakPeriod` | application/json | The recorded break. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/break-periods/{id} Remove one of your breaks - Scope: `thymian:track` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The break was removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Budgets ### GET /api/v1/budget-report-charts/{token} Serve a budget report’s chart The PNG chart a scheduled budget report carried, for a signed token found in that report’s mail or Slack post. The image is the snapshot that was sent; it never changes and never shows anything newer. Deliberately not authenticated with a session or a bearer token, for the reason `GET /avatars/{token}` is not. Invalid, expired and unknown tokens all answer 404. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `token` | `string` | yes | Opaque signed token, with a `.png` suffix, from a report. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | image/png | The chart. | **Problems** (RFC 9457 `application/problem+json`): 404. ### GET /api/v1/budgets List budgets Every budget of the organization, or of one customer. Newest span first. Archived budgets are excluded by default. Archiving is *not* the same as having been superseded: a superseded budget keeps a `validUntilMonth` and stays listed, because it is what was agreed for those months. Archiving is for one created by mistake. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer. | | `includeArchived` | `string` | no | Whether archived budgets should be included. Defaults to false. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The budgets.`{ data: Budget[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/budgets Agree a new budget For a budget that *replaces* an existing one, use `POST /budgets/supersede` instead: it ends the predecessors and writes the successor in one transaction, which is what one agreed change should be. Answers 409 when any of the projects already runs against a budget in an overlapping period — unless a declared overflow order links the two into one chain, which is the sanctioned overlap: hours fill the upstream budget first and everything beyond draws down the pot it names. Nothing in the database enforces the refusal — this check is what does. `overflowFromId` points an existing budget’s overflow at the new one, in the same transaction — the only way a supplementary pot covering the same projects can come into being, since the pointer is what sanctions the overlap. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer. | | `name` | `string` | yes | A human-readable name for the agreement; it remains stable when the project composition changes. | | `validFromMonth` | `string` | yes | The first calendar month covered by the budget, inclusive. | | `validUntilMonth` | `string \| null` | yes | The last calendar month covered by the budget, inclusive. `null` keeps it open-ended. | | `budgetedSeconds` | `integer` | yes | The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`. | | `kind` | `"monthly" \| "total"` | no | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `overflowIntoId` | `string (uuid) \| null` | no | Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused. | | `overflowFromId` | `string (uuid) \| null` | no | Optionally name one live budget of the same customer whose overflow should point to this new budget in the same transaction. This creates a supplementary pot safely; it is refused if that source already overflows elsewhere. | | `projectIds` | `string (uuid)[]` | yes | One or more project IDs covered by the budget. Every project must belong to `customerId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `Budget` | application/json | The budget. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### GET /api/v1/budgets/{id} One budget - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Budget` | application/json | The budget. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/budgets/{id} Correct a budget For correcting a mistake — a typo in the name, a mistyped number, the wrong project. A *change in what was agreed* belongs in `POST /budgets/supersede` instead, so that the months before it keep saying what was true then. The budget is validated as it would be **after** the patch, so a partial change cannot walk it into an overlap — nor past a threshold it already carries: a correction that would leave a configured alert this budget could no longer watch is refused, and the alert is removed first. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `validFromMonth` | `string` | no | A calendar month, `YYYY-MM`. | | `validUntilMonth` | `string \| null` | no | A calendar month, `YYYY-MM`. | | `budgetedSeconds` | `integer` | no | The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`. | | `kind` | `"monthly" \| "total"` | no | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `overflowIntoId` | `string (uuid) \| null` | no | Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused. | | `projectIds` | `string (uuid)[]` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Budget` | application/json | The budget. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/budgets/{id} Delete a budget nothing was measured against For one entered by mistake. Answers **409** as soon as a single billable hour has been tracked against it: from that moment it is the record of what those months were measured against, and the answer is to archive it instead. The line is ADR-0077’s own — *a budget that was consumed is a record* — rather than a blanket refusal, because a budget nobody measured anything against is a record of nothing, and archiving it would leave a row that has to be explained forever. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/budgets/{id}/adjustments/{month} Adjust one month of a budget For a carry-over from the month before, or any other one-off change to that single month. `adjustmentSeconds` is a **delta** on what was agreed and may be negative — a month worked over the agreement leaves the next one with less. It must not be zero: no adjustment is the absence of a row, so use `DELETE` for that. Idempotent. A month already adjusted is overwritten, because one month has one answer and correcting a carry-over is an edit rather than a second, compensating row. Omitting `note` therefore clears it, the way a `PUT` should. Answers 422 for a month outside the budget’s span — there is nothing to adjust there, and a month with no budget at all is answered by agreeing a one-month budget for it. An archived budget is 422 as well rather than 404: it exists, it just covers no month. A `total` budget is also 422: its capacity is one figure for the whole span, and correcting *that* is what `PATCH` and superseding are for. Nothing recomputes this: it is what somebody decided, so a later correction to the previous month does not move it. Carrying a balance further means adjusting the next month too, and letting it lapse means not doing so — the arrangements behind that (carried over here, expired after three months there) are deliberately not modelled. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `adjustmentSeconds` | `integer` | yes | A signed delta in whole seconds, added to the budget’s agreed capacity for this month alone. Use a negative value when earlier work consumed capacity; this is not a replacement total and zero must be removed instead. | | `note` | `string \| null` | no | Optional explanation for why this month differs. Free text; `null` clears the note. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BudgetAdjustment` | application/json | The adjustment. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/budgets/{id}/adjustments/{month} Remove a month’s adjustment The month goes back to exactly what the budget says. Unguarded, unlike `DELETE /budgets/{id}`: the budget is the record of what was agreed, while an adjustment is bookkeeping about one month — the thing somebody revises when they realise they carried the wrong number over. Removing it restores a state that was true before it rather than losing history. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/budgets/{id}/alerts One budget’s alert thresholds The same shape the collection answers with, for one budget — including one that has no threshold yet, which is exactly the budget somebody opens this for. A **crossing** reports itself once: a `total`-scope threshold has one standing crossing, a `month`-scope one has one per month. A reading that falls back below the threshold resolves that crossing and arms the threshold again, so the next crossing records and reports itself anew. The figures on a crossing are the ones read at the moment it happened and are never recomputed — what a later booking did to the budget is the budget’s own answer, not this one’s. `availableScopes` is what this budget can carry, and a client offers nothing else: a `total` budget is one figure for its whole span and has no monthly share to be a percentage of, a monthly budget with no end month has no total, and a span longer than 24 months has no total this system can read. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BudgetAlerts` | application/json | The thresholds. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/budgets/{id}/alerts Watch a percentage of a budget The percentage is free-form between 1 and 1000: 80 and 100 are the usual pair, and a value above 100 watches an overrun. Whoever subscribed to budget alerts in their profile is mailed once per crossing — and again once the hours have fallen back below the threshold and later reach it anew. Answers 422 for a scope this budget cannot carry, rather than storing a threshold that could never fire — `availableScopes` on the `GET` says which ones it can. A percentage this budget already watches in the same scope is 409: two identical thresholds would mail the same figure twice. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `scope` | `"month" \| "total"` | yes | What the percentage is taken of: `month` is one calendar month of a monthly budget, `total` the whole agreed span. Which of the two a budget can carry follows from the agreement rather than from preference — read `availableScopes` and offer nothing else. | | `percent` | `integer` | yes | The share of the agreed hours that makes this threshold fire, as a whole percent from 1 to 1000. Free-form within that range rather than a fixed set: 80 and 100 are the usual pair, 50 and 120 are as valid. Above 100 watches an overrun. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `BudgetAlertThreshold` | application/json | The threshold. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/budgets/{id}/alerts/{alertId} Stop watching a percentage Removes the threshold and what it recorded. Unguarded, unlike deleting a budget: a threshold is a standing request to be told something, not a record of what was agreed, and a mail already sent is recorded elsewhere. Entering the same percentage again therefore starts over — it may fire a second time for a window it already fired for, which is what asking for it again means. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `alertId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/budgets/{id}/archive Archive or restore a budget Budgets are archived rather than deleted: one that was consumed is the record of what the hours were measured against. This is for a budget created by mistake. A budget that simply *ended* has a `validUntilMonth` and stays listed. Restoring one answers **409** when a live budget has taken over its name and start month in the meantime — which is a realistic sequence, because "archive it and enter it again" is the recommended flow. The message names the budget in the way, since archived budgets are what the person is looking at and the live one is filtered out of that list. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `archived` | `boolean` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Budget` | application/json | The budget. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/budgets/{id}/reports One budget’s scheduled reports The caller’s own e-mail report of this budget, if any, and every Slack report of it. Somebody else’s e-mail report is theirs alone and never listed here. A report states where the budget stands this month and both forecasts to the end of its period — by plan where one exists, and by the pace so far — with a chart. Hours only, never money. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BudgetReports` | application/json | The reports. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PUT /api/v1/budgets/{id}/reports/email Subscribe to a budget’s report by e-mail Creates or reschedules the caller’s own e-mail report of this budget. It goes to the caller’s account address — nobody subscribes a colleague. Whether the caller may still read the budget is checked again before every report. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `sendWeekdays` | `integer[]` | yes | ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed. | | `sendHour` | `integer` | yes | The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BudgetReportSubscription` | application/json | The subscription. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/budgets/{id}/reports/email Unsubscribe from a budget’s e-mail report Removes the caller’s own e-mail report of this budget. 404 when there is none. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/budgets/{id}/reports/slack Post a budget’s report to a Slack channel Everybody in the channel reads what the report says, including people who could not open the budget themselves — so this needs `budget.write`, and each post rides on the authority of the person who set it up, checked again before every post. A public channel needs no invitation; a private one only works once the Timjami app was invited. 409 when this channel already gets this budget’s report, or when no Slack workspace is connected. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `slackChannelId` | `string` | yes | The Slack channel id, as `GET /budgets/report-channels` lists it. | | `sendWeekdays` | `integer[]` | yes | ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed. | | `sendHour` | `integer` | yes | The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `BudgetReportSubscription` | application/json | The subscription. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PATCH /api/v1/budgets/{id}/reports/slack/{subscriptionId} Reschedule a Slack report Changes the days and the hour a channel gets this budget’s report. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `subscriptionId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `sendWeekdays` | `integer[]` | yes | ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed. | | `sendHour` | `integer` | yes | The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BudgetReportSubscription` | application/json | The subscription. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/budgets/{id}/reports/slack/{subscriptionId} Stop posting a budget’s report to a channel Removes the Slack report and the record of what it sent. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `subscriptionId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/budgets/{id}/trajectory Where one budget is heading, day by day The budget’s trajectory as in `GET /budgets/trajectory`, and its burn-up over every working day of the period: consumed to date, placed on the day its entries started (an allowance month on its first day), the target spread evenly over the working days, and the forecast from today to the end at its own daily rate. The consumed line ends at the trajectory’s `consumedSeconds`. 404 when the budget has no trajectory this month — no period covering today, an open-ended total budget, or a budget the caller cannot see (ADR-2026-09-17-14UV6). - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BudgetTrajectoryDetail` | application/json | The trajectory and its days. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/budgets/alerts Every watched budget’s thresholds and what they recorded One entry per budget that carries at least one threshold — a budget nobody configured an alert on is absent rather than present and empty, since „keine Alarme" is the same answer either way and listing budgets is what `GET /budgets` does. For a screen that lists budgets and wants to mark the thresholds a reading reaches right now — a crossing with a `null` `resolvedAt` — in one request instead of one per row. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | Limit the answer to the budgets of one customer. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The watched budgets.`{ data: BudgetAlerts[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/budgets/consumption What was agreed and what was tracked, per month One entry per month in `[fromMonth, untilMonth]`, both inclusive — a month with no budget is returned with an empty list rather than omitted, because "nothing was agreed for October" is an answer. `consumedSeconds` counts **billable, finished** entries on the budget’s projects. Whether an entry carries a rate is irrelevant: a budget is hours. Months are resolved in the organization’s own time zone. A budget in a declared overflow chain shows what the chronological allocation left in it: hours fill a pot up to its capacity — per month for `monthly`, per span for `total` — and everything beyond draws down the pot it names, exact to the second, the boundary entry included. A `total` budget’s figures describe its whole span and repeat identically in every covered month. The months come in calendar order, and inside one the budgets come **grouped by customer**: a budget belongs to exactly one customer, so that is the question this answer is read with, and a client may rely on one customer’s budgets being contiguous. Inside a customer the largest overrun comes first. `unbudgeted` is ordered the same way. Two things travel beside the numbers rather than being hidden: `unbudgeted` lists projects whose billable hours no budget covers, and `overlaps` lists any project claimed by two budgets in the same month with no fill order between them — a state the model allows and this endpoint is required to surface. A declared chain is the one sanctioned overlap and is not reported. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `fromMonth` | `string` | yes | The first consumption month to return, inclusive. | | `untilMonth` | `string` | yes | The final consumption month to return, inclusive. It cannot precede `fromMonth`; the range may cover at most 24 months. | | `customerId` | `string (uuid)` | no | The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The months.`{ data: BudgetMonth[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/budgets/report-channels Slack channels a budget report can be posted to Every unarchived public channel of the connected workspace, and the private channels the Timjami app was invited to. 409 when no workspace is connected. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The channels.`{ data: BudgetReportChannel[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### POST /api/v1/budgets/supersede Replace budgets with the one agreed next Ends every budget in `replaceIds` the month **before** the successor starts and writes the successor, in one transaction. That is the shape of the change people actually agree: "from October we have 250 hours for both projects" is one thing, even when it ends two budgets and starts one. Answers 422 when a predecessor belongs to another customer, starts *after* the successor does — which would ask for a span that closes before it opened — or has already ended earlier, since superseding only ever shortens a span and never extends one over months nobody agreed it for. - Scope: `thymian:budgets.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `replaceIds` | `string (uuid)[]` | yes | The budgets this one replaces. Each is ended the month before the successor starts, in the same transaction. Several IDs allow a fan-in replacement. All must belong to the successor’s customer. | | `successor` | `object` | yes | The new budget that replaces every `replaceIds` budget in the same transaction. | | `successor.customerId` | `string (uuid)` | yes | The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer. | | `successor.name` | `string` | yes | A human-readable name for the agreement; it remains stable when the project composition changes. | | `successor.validFromMonth` | `string` | yes | The first calendar month covered by the budget, inclusive. | | `successor.validUntilMonth` | `string \| null` | yes | The last calendar month covered by the budget, inclusive. `null` keeps it open-ended. | | `successor.budgetedSeconds` | `integer` | yes | The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`. | | `successor.kind` | `"monthly" \| "total"` | no | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `successor.overflowIntoId` | `string (uuid) \| null` | no | Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused. | | `successor.overflowFromId` | `string (uuid) \| null` | no | Optionally name one live budget of the same customer whose overflow should point to this new budget in the same transaction. This creates a supplementary pot safely; it is refused if that source already overflows elsewhere. | | `successor.projectIds` | `string (uuid)[]` | yes | One or more project IDs covered by the budget. Every project must belong to `customerId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `Budget` | application/json | The successor. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### GET /api/v1/budgets/trajectory Where each budget of the current month is heading One trajectory per budget covering the organization’s current month: what should be consumed by today, a forecast for the end of the period, and whether it deviates enough to act on. A `monthly` budget’s period is the month; a `total` budget’s is its span, and one without an end month has no trajectory. Working days are Monday to Friday, today included. The forecast is consumed plus the remaining planned work when anything is planned against the budget in its period (`basis: plan`, the same figure as `GET /planning/forecast`; always `basis: pace` when the organization has switched planning off), and otherwise the average per elapsed working day carried over the remaining ones (`basis: pace`). A deviation under 10 % of capacity or under eight hours is `on-track`. Requires `budget.readForecast`, which every member holds: the answer is a per-budget aggregate and names no person, week or capacity (ADR-2026-09-17-14UV6). - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `BudgetTrajectories` | application/json | The current month’s trajectories. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Capacity agreements ### GET /api/v1/capacity-agreements Every stored capacity agreement version Requires the `owner` or `admin` role. Oldest first, per person. Every stored version rather than „the one in effect": resolving a day, a week or a window needs the whole list to pick from, and a period something changed inside needs the versions beginning before it too. A person with no version at all is absent here — „unklassifiziert" is a state somebody can be in, not a row with null fields, and they count in no figure until a version covers the day in question. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Only this person’s versions. Omitted, every person’s. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `CapacityAgreements` | application/json | The stored versions. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/capacity-agreements Record a new capacity agreement version Requires the `owner` or `admin` role. A new agreement, never a correction — `PATCH` is for a typo in an existing version. There is no `validUntilDay`: the next version ends the previous one. `kind` picks which side is stated. `share` is a percentage of contracted time and follows a contract change by itself, which is what „we sell all of you" means. `fixed` is an absolute sold week and survives one, which is what „the two of us are one FTE together" means — recorded as the agreement rather than as the percentage it currently works out to, so nobody has to recompute it when a contract moves. Ending somebody’s participation is a `share` version at `"0.00"` from the day it ends, never a deleted row: the periods they were present must keep counting them. - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Request body (application/json)**: CreateCapacityAgreement **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CapacityAgreement` | application/json | The stored version. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### PATCH /api/v1/capacity-agreements/{id} Correct one stored capacity agreement version Requires the `owner` or `admin` role. For a mistyped share, a wrong start day or a note — a capacity that *changed* on a date is a new version, not a correction of the old one. Switching `kind` is an ordinary correction: send the new kind with its own side, and the other side is cleared for you. The merged row is what is checked, so a patch leaving the two sides contradicting each other is a 422 rather than a stored row nobody can read. - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `validFromDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `kind` | `"share" \| "fixed"` | no | share = a percentage of contracted time, which follows a contract change by itself. fixed = an absolute sold week, which survives one by itself. | | `sharePercent` | `string \| null` | no | | | `soldSecondsPerWeek` | `integer \| null` | no | | | `employment` | `"internal" \| "external"` | no | internal (employed) or external (freelancer). | | `inTeamMetric` | `boolean` | no | | | `note` | `string \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `CapacityAgreement` | application/json | The corrected version. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/capacity-agreements/{id} Remove one capacity agreement version Requires the `owner` or `admin` role. Removes **one version**; the previous one then reaches forward, and removing the only version returns the person to „unklassifiziert", counting in no figure and named in the report’s `warnings`. This is a correction path, not how somebody leaves. Ending a participation is a `share` version at `"0.00"` from the day it ends — a delete removes the agreement from the past as well, so the periods the person was present stop counting them. Deleting the earliest version is the one delete that moves published history, and nothing prevents it: everything before the next version becomes unclassified. - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The version is gone. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Changelog ### GET /api/v1/changelog/{kind} A product's changelog - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"cli" \| "cli-native" \| "desktop" \| "extension" \| "firefox-extension" \| "safari-extension" \| "web" \| "ios"` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Every version this product's CHANGELOG.md records, newest first. Only web includes unreleased changesets already deployed in this build; unreleased is empty for every other product.`{ kind: "cli" \| "cli-native" \| "desktop" \| "extension" \| "firefox-extension" \| "safari-extension" \| "web" \| "ios", entries: object[], entries.version: string, entries.changes: object[], unreleased: object[], unreleased.type: "major" \| "minor" \| "patch" \| null \| null, unreleased.headline: string \| null, unreleased.note: string }` | ### GET /api/v1/changelog/{kind}/status Whether a product has unreleased changes A single boolean, so a client that only needs to know whether to mark its version label does not have to download every release. Only web can be true: it is the one product whose unreleased changesets are deployed with the API. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"cli" \| "cli-native" \| "desktop" \| "extension" \| "firefox-extension" \| "safari-extension" \| "web" \| "ios"` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Whether the deployed build has changes not yet in a release.`{ kind: "cli" \| "cli-native" \| "desktop" \| "extension" \| "firefox-extension" \| "safari-extension" \| "web" \| "ios", hasUnreleased: boolean }` | ## Customer portal ### GET /api/v1/customer-portal/{organizationSlug} Get the invited customer portal overview - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `organizationSlug` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Customer and currently active projects.`{ customer: object, customer.id: string (uuid), customer.name: string, customer.color: string, projects: object[], projects.id: string (uuid), projects.name: string, projects.color: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/customer-portal/{organizationSlug}/branding Get public customer portal branding - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `organizationSlug` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Portal branding.`{ organizationSlug: string, displayName: string, accentColor: string, welcomeMessage: string \| null, logoUrl: string (uri) \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/customer-portal/{organizationSlug}/budgets Get the shared budgets of the invited customer Hours only, for the current and the previous month in the organization time zone. Lists only budgets the organization shared whose every project the portal shows (ADR-2026-09-28-22UCR). - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `organizationSlug` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Shared budget consumption, oldest month first.`{ months: object[], months.month: string, months.budgets: object[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/organization/customer-portal Get customer portal settings and invitations - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Portal administration.`{ branding: object, branding.displayName: string \| null, branding.accentColor: string, branding.welcomeMessage: string \| null, projectAccess: object, projectAccess.mode: "all" \| "selected", projectAccess.selectedProjectIds: string (uuid)[], projectAccess.availableProjects: object[], budgetAccess: object, budgetAccess.selectedBudgetIds: string (uuid)[], budgetAccess.availableBudgets: object[], grants: object[], grants.id: string (uuid), grants.customerId: string (uuid), grants.customerName: string, grants.email: string (email), grants.acceptedAt: string (date-time) \| null, grants.revokedAt: string (date-time) \| null, grants.createdAt: string (date-time) }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/organization/customer-portal/branding Update customer portal branding - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `displayName` | `string \| null` | yes | | | `accentColor` | `string` | yes | | | `welcomeMessage` | `string \| null` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Updated branding.`{ displayName: string \| null, accentColor: string, welcomeMessage: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/organization/customer-portal/budgets Choose budgets shared in the customer portal Replaces the whole list. A shared budget is shown only to its own customer's contacts, and only while the portal shows every project it covers. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `budgetIds` | `string (uuid)[]` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Updated budget access.`{ selectedBudgetIds: string (uuid)[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/organization/customer-portal/invitations Invite a customer contact - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `email` | `string (email)` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Invitation sent.`{ status: "sent" }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/organization/customer-portal/invitations/{id} Revoke a customer portal invitation - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Invitation revoked. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/organization/customer-portal/projects Choose projects visible in the customer portal - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)**: opaque — see the OpenAPI document **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Updated project access.`{ mode: "all" \| "selected", selectedProjectIds: string (uuid)[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Easybill ### PUT /api/v1/customers/{id}/easybill Point a customer at a specific Easybill customer Set automatically on first export when exactly one Easybill customer matches by name. Use this to resolve an ambiguous match, or to point a customer at one that already has an address in Easybill instead of the bare stub an export would otherwise create. The id is read back from Easybill before it is stored, so a customer that does not exist there is refused here rather than at the next export — which means this route needs Easybill to be reachable. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `easybillCustomerId` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillCustomerMapping` | application/json | The mapping. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/customers/{id}/easybill Forget the Easybill mapping The next export re-resolves it — by name again, or creates a new stub. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/easybill The Easybill connection `configured: false` hides the settings card and refuses an export. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/easybill Connect Easybill, or replace the key and service article The key is encrypted at rest — see ADR-2026-08-13-10F4L. Refused (409) while sevdesk is connected: an organization has at most one invoicing tool. `easybillServicePositionId` is the exception: omitting it **keeps** the article already chosen, so rotating a key cannot silently disable exporting (an export is refused while no article is set — ADR-2026-08-19-21X38). Given, it is read back from Easybill before it is stored, so this route then needs Easybill reachable. `PUT /easybill/service` is that field’s own route, and the one the web app uses. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `apiKey` | `string` | yes | Easybill's Bearer API key, from the account's own settings. | | `easybillServicePositionId` | `string` | no | The Easybill article's id. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### DELETE /api/v1/easybill Disconnect Easybill Removes the stored key and every customer mapping. Documents already created in Easybill are unaffected — regenerate the key in Easybill’s own settings if it may have leaked. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Disconnected. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/easybill/customer-mappings Every customer’s Easybill mapping Timjami’s own rows — nothing here reads Easybill. One answer for a whole screen rather than a request per customer; a customer with no mapping is simply absent. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillCustomerMappingList` | application/json | The mappings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/easybill/customers List Easybill customers, for the mapping picker Reads live from Easybill, through the organization’s stored key. One page of up to 1000, never a walk through every page: Easybill’s rate limit is per minute and shared with the exports. `pages` greater than `page` means the account has more than this answer shows — narrow it with `companyName`, which is Easybill’s own filter and therefore finds no `personal` customer, whose name is not a company name. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `companyName` | `string` | no | | | `page` | `integer` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillCustomerList` | application/json | One page of Easybill customers. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/easybill/expense Choose the Easybill article a fixed-price expense references A fixed-price `expense` (travel costs, material) usually carries another VAT rate or ledger account than a day fee, so its invoice line references this article rather than the service article. An item’s own `easybillPositionId` overrides it. Until one is chosen, exporting an invoice with an expense that has no article of its own is refused (422) rather than printing it at the service article’s rate. `null` clears it. Chosen from the same list as the service article (`GET /easybill/services`) and read back from Easybill before it is stored, so this route needs Easybill reachable. [ADR-2026-10-17-23L6T](../../../../docs/decisions/2026-10-17-23L6T.md) - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `easybillExpensePositionId` | `string \| null` | yes | The Easybill article's id. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/easybill/service Choose the Easybill service article every exported line references The article carries the VAT rate, the unit and the Fibu-Konto: an exported position sends its id and no rate at all, and Easybill copies those three onto the document while Timjami’s description, quantity and price win (ADR-2026-08-19-21X38). An export is refused while none is chosen, rather than sending a document at 0 %. Its own route rather than a field on `POST /easybill`, because the picker it is chosen from can only read Easybill once a key is stored — and because rotating a key must not clear it. The id is read back from Easybill before it is stored, so this route needs Easybill reachable. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `easybillServicePositionId` | `string` | yes | The Easybill article's id. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/easybill/services List Easybill service articles, for the settings picker Reads live from Easybill, through the organization’s stored key. One page of up to 1000 `SERVICE` articles — with `purpose=expense`, one page each of `SERVICE` and `PRODUCT` articles, because an expense article is often a product in Easybill — never a walk through every page: Easybill’s rate limit is per minute and shared with the exports. `pages` greater than `page` means the account has more than this answer shows. `vatPercent` is here to make the choice checkable and is stored nowhere — the article is what prices an exported line (ADR-2026-08-19-21X38). Easybill exposes no article id in its own UI, which is why this list exists rather than a field to paste one into. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `purpose` | `"service" \| "expense"` | no | | | `page` | `integer` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `EasybillServiceList` | application/json | One page of Easybill service articles. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ## sevdesk ### PUT /api/v1/customers/{id}/sevdesk Point a customer at a specific sevdesk contact Set automatically on first export when exactly one sevdesk **customer** has the customer’s exact name. Use this to resolve a 409 — for several customers of that name, or for a contact of that name that is not a customer (a supplier, say) — or to point a customer at one that already has an address in sevdesk instead of the bare contact an export would create. The id is read back from sevdesk before it is stored, so a contact that does not exist there is refused here rather than at the next export — which means this route needs sevdesk to be reachable. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `sevdeskContactId` | `string` | yes | sevdesk's own id. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskCustomerMapping` | application/json | The mapping. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/customers/{id}/sevdesk Forget the sevdesk mapping The next export re-resolves it — by exact name again, or creates a new contact. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/sevdesk The sevdesk connection `configured: false` hides the settings card and refuses an export. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/sevdesk Connect sevdesk, or replace the token The token is encrypted at rest — see ADR-2026-08-13-10F4L. It is checked against the account before anything is stored, in this order, refusing on the first that fails: any authenticated read (a rejected token is 422 on `apiToken`), the bookkeeping version (only `2.0` is served — an account on 1.0 expects another payload), the account’s settings, the users, and each chosen part. Whether the account is on the Buchhaltung Pro plan, which the API needs, cannot be read; the settings card says so. Each choice is optional and **kept** when omitted, so rotating the token cannot silently disable exporting; the routes `PUT /sevdesk/service`, `/expense`, `/contact-person` and `/tax-rule` are the fields’ own. Refused (409) while Easybill is connected: an organization has at most one invoicing tool. The answer names the tax rule the account’s own settings suggest, and warns when the chosen one departs from them. [ADR-2026-10-22-09785](../../../../docs/decisions/2026-10-22-09785.md) - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `apiToken` | `string` | yes | The account's sevdesk API token, from the settings of the sevdesk user. | | `servicePartId` | `string` | no | sevdesk's own id. | | `expensePartId` | `string` | no | sevdesk's own id. | | `contactPersonId` | `string` | no | sevdesk's own id. | | `taxRuleId` | `1 \| 11` | no | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskConnectionResult` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### DELETE /api/v1/sevdesk Disconnect sevdesk Removes the stored token and every customer mapping. Invoices already created in sevdesk are unaffected — revoke the token in sevdesk if it may have leaked. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Disconnected. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/sevdesk/account What the sevdesk account says about its tax rule Reads `GET /SevClient` live with the stored token: whether the account is a Kleinunternehmer (§19), the tax rule that suggests, and — when a rule is chosen — a warning if it departs from that. sevdesk does not compare the two itself, so this is where a person is told. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskAccount` | application/json | The account’s tax settings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/sevdesk/contact-person Choose the sevdesk user printed as „Ihr Ansprechpartner“ The user’s name is printed on every exported invoice. Chosen from `GET /sevdesk/contact-persons` and read back from sevdesk before it is stored. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `contactPersonId` | `string` | yes | sevdesk's own id. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/sevdesk/contact-persons List the sevdesk users, for the contact-person picker Reads `GET /SevUser` live with the stored token. The chosen user’s name is printed on every exported invoice as „Ihr Ansprechpartner“. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskContactPersonList` | application/json | The account’s users. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/sevdesk/contacts List sevdesk contacts, for the mapping picker Reads `GET /Contact` live with the stored token — contacts of every category, each with its `categoryId`, so a supplier can be chosen when the automatic match refused one (ADR-2026-10-23-11AWL). One page of up to 1000, never a walk through every page; `pages` greater than `page` means the account has more than this answer shows. `name` narrows it with sevdesk’s own filter, a case-insensitive substring match. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `page` | `integer` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskContactList` | application/json | One page of sevdesk contacts. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/sevdesk/customer-mappings Every customer’s sevdesk mapping Timjami’s own rows — nothing here reads sevdesk. One answer for a whole screen rather than a request per customer; a customer with no mapping is simply absent. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskCustomerMappingList` | application/json | The mappings. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/sevdesk/expense Choose the sevdesk part whose tax rate an expense is exported with A fixed-price `expense` (travel costs, material) usually carries another tax rate than a day fee, so its position takes the rate of this part rather than the service part’s. Until one is chosen, exporting an invoice with an expense is refused (422). `null` clears it. Read back from sevdesk before it is stored. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `expensePartId` | `string \| null` | yes | sevdesk's own id. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/sevdesk/parts List sevdesk parts, for the settings pickers Reads `GET /Part` live with the stored token: one page of up to 1000, never a walk through every page. `mayHaveMore` means the page was full and the account may have more. `vatPercent` makes the choice checkable and is stored nowhere — the export reads the chosen part’s rate at export time. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskPartList` | application/json | One page of sevdesk parts. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/sevdesk/service Choose the sevdesk part whose tax rate time lines and services are exported with sevdesk copies nothing from a part onto a position, so the export reads this part’s `taxRate` at export time and sends it on every position and on the invoice head. Timjami stores which part, never a rate. An export is refused while none is chosen. The id is read back from sevdesk before it is stored, so this route needs sevdesk reachable. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `servicePartId` | `string` | yes | sevdesk's own id. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskConnection` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/sevdesk/tax-rule Choose the tax rule every export sends Only rule `1` (taxable in Germany) and `11` (§19) can be chosen — the two verified on a draft. The answer warns when the choice departs from the account’s own `smallSettlement`, which sevdesk does not compare itself; the warning never refuses. sevdesk does compare the rule with each position’s rate, so a part whose rate does not fit the rule is refused at export time. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `taxRuleId` | `1 \| 11` | yes | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SevdeskConnectionResult` | application/json | The connection. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ## Dashboards ### GET /api/v1/dashboards Every saved dashboard this person may open Requires the `owner` or `admin` role. Your own dashboards, whatever their visibility, plus every `shared` one in this organization. Somebody else’s `private` dashboard is not listed and not reachable. `isOwn` says whether you may change it; `ownerName` says whose it is. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Dashboards` | application/json | The dashboards visible to the caller. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/dashboards Save a new dashboard Requires the `owner` or `admin` role. The authenticated person becomes the owner and is the only one who may change it afterwards. `visibility` defaults to `private`. `layout.widgets` is the ordered placement; each `widget` key is stored as sent and never checked against a list of known widgets, so a client may save a layout this server has never heard of. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `visibility` | `"private" \| "shared"` | no | private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing. | | `layout` | `DashboardLayout` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `Dashboard` | application/json | The saved dashboard. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/dashboards/{dashboardId} One saved dashboard Requires the `owner` or `admin` role. Your own, or anybody’s `shared` one. Somebody else’s `private` dashboard answers 404 rather than 403 — naming it as forbidden would confirm that it exists. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `dashboardId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Dashboard` | application/json | The dashboard. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/dashboards/{dashboardId} Change a dashboard you own Requires the `owner` or `admin` role, **and** ownership. Another admin reading a `shared` dashboard may not edit it: that is 403, because they can already see it and the honest answer is „not yours". A dashboard you cannot see at all is still 404. Send only what changes. A `layout` replaces the stored one whole — there is no per-widget patch, because the arrangement is the thing being saved. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `dashboardId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `visibility` | `"private" \| "shared"` | no | private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing. | | `layout` | `DashboardLayout` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `Dashboard` | application/json | The changed dashboard. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/dashboards/{dashboardId} Delete a dashboard you own Requires the `owner` or `admin` role, **and** ownership — the same split as `PATCH`: a `shared` dashboard somebody else saved is 403, one you cannot see is 404. Deleting a shared dashboard removes it for everybody; there is no copy left behind. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `dashboardId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The dashboard is gone. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Downloads ### GET /api/v1/downloads List client downloads - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The currently published CLI, macOS app and browser extension releases — the CLI as the Node.js bundle and as one native binary per platform, the extension in each of its Chrome, Firefox and Safari shapes.`{ downloads: object[], downloads.kind: "cli" \| "cli-linux-x86_64" \| "cli-linux-aarch64" \| "cli-macos-aarch64" \| "cli-macos-x86_64" \| "cli-windows-x86_64" \| "desktop" \| "extension" \| "firefox-extension" \| "safari-extension", downloads.version: string, downloads.platform: string, downloads.byteSize: integer, downloads.downloadUrl: string, downloads.fileName: string }` | | 404 | — | — | Downloads are not configured or no matching releases exist. | ### GET /api/v1/downloads/{kind} Download a client - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"cli" \| "cli-linux-x86_64" \| "cli-linux-aarch64" \| "cli-macos-aarch64" \| "cli-macos-x86_64" \| "cli-windows-x86_64" \| "desktop" \| "extension" \| "firefox-extension" \| "safari-extension"` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | application/octet-stream | Release asset bytes, streamed through Timjami. | | 404 | — | — | Downloads are not configured or this product has no release. | ### POST /api/v1/downloads/cli-native/install-tickets Create one-time installer URLs for the native CLI Mints the same ten-minute, single-use capability as `POST /downloads/cli/install-tickets` and answers two installer addresses for it: a POSIX `sh` script for Linux and macOS, and a PowerShell script for Windows. Fetching either script does not use the capability up; downloading the binary the script selects does. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `object` | application/json | A ten-minute, single-use installer capability, as two scripts.`{ installUrl: string (uri), powershellUrl: string (uri), expiresAt: string (date-time) }` | | 404 | — | — | CLI downloads are not configured. | ### GET /api/v1/downloads/cli-native/install/{ticket} Native CLI installer for Linux and macOS A POSIX `sh` script that detects the platform, downloads the matching binary through this ticket, and installs it as `timjami`. Fetching it does not redeem the ticket. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `ticket` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string` | text/x-shellscript | POSIX shell installer for the native CLI. | | 404 | — | — | The capability is unknown, expired, used, or unavailable. | ### GET /api/v1/downloads/cli-native/install/{ticket}/{kind} Redeem a one-time native CLI download The binary the installer scripts download. Redeems the ticket: it answers once, then 404. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `ticket` | `string` | yes | | | `kind` | `"cli-linux-x86_64" \| "cli-linux-aarch64" \| "cli-macos-aarch64" \| "cli-macos-x86_64" \| "cli-windows-x86_64"` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | application/octet-stream | The native CLI binary for this platform, streamed through Timjami. | | 404 | — | — | The capability is unknown, expired, used, or unavailable. | ### GET /api/v1/downloads/cli-native/install/{ticket}/powershell Native CLI installer for Windows A PowerShell script, for `irm … | iex`, that downloads the Windows binary through this ticket and installs it as `timjami.exe`. Fetching it does not redeem the ticket. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `ticket` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string` | text/plain | PowerShell installer for the native CLI. | | 404 | — | — | The capability is unknown, expired, used, or unavailable. | ### POST /api/v1/downloads/cli/install-tickets Create a one-time CLI installer URL - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `object` | application/json | A ten-minute, single-use installer capability.`{ installUrl: string (uri), expiresAt: string (date-time) }` | | 404 | — | — | CLI downloads are not configured. | ### GET /api/v1/downloads/cli/install/{ticket} Native CLI installer at its former address The same POSIX `sh` installer as `GET /downloads/cli-native/install/{ticket}`: it installs the native CLI, and fetching it does not redeem the ticket. It once streamed the Node.js CLI installer, which has ended. - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `ticket` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string` | text/x-shellscript | POSIX shell installer for the native CLI. | | 404 | — | — | The capability is unknown, expired, used, or unavailable. | ### GET /api/v1/downloads/desktop/updater-artifact/{tag} Download the desktop app updater artifact - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `tag` | `string` | yes | | **Header parameters** | Field | Type | Required | Description | |---|---|---|---| | `x-thymian-update-channel` | `string` | no | Must match the channel sent to the manifest request that named this tag (`stable` or `beta`) — a caller still on stable cannot download a beta-only artifact by tag. Omitted or anything but exactly `beta` means `stable`, same as the manifest endpoint. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | application/octet-stream | The signed updater bundle, streamed through Timjami. | | 404 | — | — | Downloads are not configured or this release has no updater artifact. | ### GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion} Desktop app update check (Tauri dynamic update server) - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `target` | `string` | yes | | | `arch` | `string` | yes | | | `currentVersion` | `string` | yes | | **Header parameters** | Field | Type | Required | Description | |---|---|---|---| | `x-thymian-update-channel` | `string` | no | Self-reported client preference (`stable` or `beta`), stored device-locally in the desktop app and never persisted server-side. Omitted or anything but exactly `beta` means `stable` — not rejected. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | A newer desktop build is published.`{ version: string, notes: string, url: string (uri), signature: string }` | | 204 | — | — | Nothing newer than the installed build. | | 404 | — | — | Downloads are not configured, this platform is not built, or no release exists. | ### GET /api/v1/downloads/firefox-extension/updates.json Firefox extension update manifest - Scope: none beyond authenticating - Credentials: carries its own credential **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Mozilla's update manifest for the self-distributed Firefox extension: the newest signed version, or no update when none is published yet.`{ addons: object }` | | 404 | — | — | Downloads are not configured. | ### GET /api/v1/downloads/firefox-extension/xpi/{tag} Download the signed Firefox extension - Scope: none beyond authenticating - Credentials: carries its own credential **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `tag` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string (binary)` | application/x-xpinstall | The package Mozilla signed for this release. | | 404 | — | — | Downloads are not configured, or this release has no signed package or is older than the newest two that have one. | ## Fixed-price work ### GET /api/v1/fixed-price-agreements List fixed-price agreements Every agreement the caller may see, newest first, each with its tasks and items. An agreement is **hidden whole** from somebody who cannot see a project it touches, through a task or an item: seeing project A but not B means not seeing the agreement at all. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | no | Only this customer’s agreements. | | `projectId` | `string (uuid)` | no | Only agreements that touch this project, through a task or an item. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The agreements.`{ data: FixedPriceAgreement[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/fixed-price-agreements Create a fixed-price agreement An agreement belongs to a customer and starts with no tasks and no items. Name it on a task with `fixedPriceAgreementId`, and add the amounts that are billed with `POST /fixed-price-items`. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `FixedPriceAgreement` | application/json | The agreement. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/fixed-price-agreements/{id} Get a fixed-price agreement - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `FixedPriceAgreement` | application/json | The agreement. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/fixed-price-agreements/{id} Rename a fixed-price agreement Only the name changes. The customer cannot: tasks and items are held to it, so a different customer is a different agreement. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `FixedPriceAgreement` | application/json | The agreement. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### DELETE /api/v1/fixed-price-agreements/{id} Delete a fixed-price agreement Answers **409** while a task or an item still names the agreement: delete the items first, and delete the tasks (possible while nothing was booked on them) or move them to another agreement or detach them (archiving a task does not release it). - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/fixed-price-agreements/{id}/controlling Read a fixed-price agreement’s controlling The hours spent on the agreement, in total and per person, split into sold work (`fixed_price` tasks) and investment (`productive` and `internal` tasks), and **two effective hourly rates side by side**, each with the hours it divides by: revenue ÷ all hours, and revenue ÷ fixed-price hours. Revenue is the `service` items’ amounts, spread over their service days — never derived from the hours. An item in another currency is counted, never summed. Rates are rounded once, half up, to a whole minor unit per hour; finished entries only. No estimate is read. Owner and admin only (`report.readOthers`): the per-person split names colleagues’ hours. Hidden whole, as a 404, from somebody who cannot see a project the agreement touches. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | no | First calendar day, in the organization’s zone, inclusive. Hours are those started on or after it, and revenue is the items’ shares on or after it. Omitted: from the start. | | `until` | `string` | no | Last calendar day, inclusive. Omitted: no end. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `FixedPriceControlling` | application/json | The agreement’s controlling. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/fixed-price-agreements/{id}/revisions Read a fixed-price agreement’s history Every write to the agreement and to its items, newest first: the state each write replaced, who wrote it, when, and the reason where a closed day was written through. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The revisions, newest first.`{ data: FixedPriceRevision[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/fixed-price-items List fixed-price items The items the caller may see, oldest first. An item of a hidden agreement is hidden with it; a standalone expense stands on its project’s visibility. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `agreementId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `standalone` | `string` | no | Only items without an agreement — the standalone expenses. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The items.`{ data: FixedPriceItem[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/fixed-price-items Add a fixed-price item An item is one amount billed outside tracked time, independent of any invoice. A `service` belongs to an agreement and names its people; an `expense` names none and may stand alone on a project. The item’s project belongs to its agreement’s customer, and every person must be a live member. `amountMinor` is computed once from quantity and unit price. Every day of the item must be open for tracking: a closed day answers **409** with `periodClosed`, and an owner or admin may write through it with an `editReason`, else **422** with `reasonRequired`. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `agreementId` | `string (uuid) \| null` | no | Required for a `service`; omit or `null` for a standalone `expense`. | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `kind` | `"service" \| "expense"` | yes | `service` is revenue that people share by weight (a workshop’s day fee) and always belongs to an agreement. `expense` is passed through and counts as no revenue (travel costs); it may stand alone on a project. | | `description` | `string` | yes | | | `quantityHundredths` | `integer` | yes | | | `unit` | `string` | yes | | | `unitPriceMinor` | `integer` | yes | | | `days` | `string[]` | yes | | | `people` | `object[]` | no | At least one for a `service`, each with a weight; none for an `expense`. Each must be a live member of the organization. | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `people.weight` | `integer` | yes | The share of the item’s revenue, relative to the other people. Equal by default. | | `easybillPositionId` | `string \| null` | no | The Easybill article overriding the kind’s own; omit or `null` for the kind’s article. Whether Easybill knows it is answered at the export. | | `editReason` | `string` | no | Why an item is being created on a closed day anyway. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `FixedPriceItem` | application/json | The item. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/fixed-price-items/{id} Get a fixed-price item - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `FixedPriceItem` | application/json | The item. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/fixed-price-items/{id} Change a fixed-price item Changing quantity or unit price recomputes `amountMinor` once. Sending `days` or `people` replaces the whole set. The kind never changes. The days the item is on now **and** the days it would be on must be open; a project change needs both projects open. Closed days answer as on create. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `agreementId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `description` | `string` | no | | | `quantityHundredths` | `integer` | no | | | `unit` | `string` | no | | | `unitPriceMinor` | `integer` | no | | | `easybillPositionId` | `string \| null` | no | The Easybill article overriding the kind’s own; omit or `null` for the kind’s article. Whether Easybill knows it is answered at the export. | | `days` | `string[]` | no | | | `people` | `object[]` | no | | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `people.weight` | `integer` | yes | The share of the item’s revenue, relative to the other people. Equal by default. | | `editReason` | `string` | no | Why an item is being changed on a closed day anyway. The days it is on now **and** the days it would move to must be open. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `FixedPriceItem` | application/json | The item. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/fixed-price-items/{id} Delete a fixed-price item Waiving an item is deleting it; the revision records who did. No time entry is touched: the hours stay on their task. Closed days answer as on create. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `editReason` | `string` | no | Why an item on a closed day is being deleted anyway; a query parameter because a DELETE carries no body. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/fixed-price-items/revisions Read a project’s standalone expenses’ history A standalone expense has no agreement to show its history on, so it is read here, by project: every write to an item of this project that has no agreement, newest first. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | A project’s standalone expenses’ history — the revisions of items without an agreement. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The revisions, newest first.`{ data: FixedPriceRevision[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Imports ### POST /api/v1/imports/harvest Import clients, projects, tasks and project members from Harvest Owner/admin only. Repeated imports match records by the Harvest id recorded on a previous run — falling back to the name within the parent only when no such link exists yet — and record which Harvest client, project and task each one came from, so a later run recognizes a Harvest rename and an export can find the same resource again. A resource this run’s Harvest response no longer lists at all is archived, the same as one Harvest itself marked inactive. A project’s Harvest user assignments are matched by email to somebody this organization already knows — every person, whether or not they can sign in, and never created here; an unmatched Harvest person is reported in `unmatchedEmails` rather than failing the import, and a membership this import previously added is removed once Harvest stops asserting it. `POST /imports/harvest/users` is what creates the missing people; running this import again afterwards assigns them. Given `accountId`/`accessToken`, those are used for this request only and never stored. Omitted, the caller’s own stored connection (`POST /harvest`) is used instead. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `accountId` | `string` | yes | | | `accessToken` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Import counts.`{ customers: object, customers.created: integer, customers.updated: integer, customers.archived: integer, projects: object, projects.created: integer, projects.updated: integer, projects.archived: integer, projects.skipped: integer, tasks: object, tasks.created: integer, tasks.updated: integer, tasks.archived: integer, links: object, links.customers: integer, links.projects: integer, links.tasks: integer, members: object, members.assigned: integer, members.removed: integer, members.unmatched: integer, unmatchedEmails: string[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### POST /api/v1/imports/harvest/users Create people from Harvest’s own user list Owner/admin only (`member.manage`). Reads Harvest’s user list and answers the three-way question about every address, matched by email — case-insensitively, fresh on every run (ADR-2026-08-17-08M4J) — against **every person this** **organization knows**, not only the people who can sign in. Somebody **both** systems carry is `existing` and is left exactly as they are, in both systems. Somebody **only** Timjami carries is named once in `withoutHarvestAccount` and likewise left alone. Somebody **only** Harvest carries is `creatable`, and `created` once this run takes them over. Taking somebody over creates **one `person` row with no account**: a subject of time this organization asserts — assignable to a project, bookable, in every report, readable as a colleague — who is not a login. No `user`, no `member`, no invitation, nothing to sign in with, and nothing withheld either: better-auth never hears of them (ADR-2026-08-31-08RPY). How such a person could later be *given* an account is deliberately not decided yet, and no route here does it. Two answers are reported rather than guessed: `ambiguous` when more than one Harvest account or more than one person carries the address, and `no-email` for a Harvest account with no address at all. A Timjami account elsewhere carrying the address is deliberately **not** reported — the address is provenance rather than identity here, it blocks nothing, and answering would name somebody outside this organization. A Harvest account Harvest itself has deactivated is importable and reported as `active: false`, deliberately not filtered: somebody who left is exactly the subject whose past hours are still wanted. `harvestUserIds` narrows the run to a selection — every Harvest person is still listed, and a `creatable` one left out answers `not-selected` — and omitting it takes over everything creatable. `preview: true` computes the same answer and writes nothing. A repeated run creates nobody twice: somebody an earlier run created is recognized by the link it recorded, before the address is compared at all, so a renamed address in Harvest still answers `existing`. This import assigns nobody to a project: run `POST /imports/harvest` again afterwards and its member sync picks the new people up by the same email match. Given `accountId`/`accessToken`, those are used for this request only and never stored; omitted, the caller’s own stored connection is used. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `accountId` | `string` | no | | | `accessToken` | `string` | no | | | `preview` | `boolean` | no | | | `harvestUserIds` | `string[]` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | What happened, or would happen, to every Harvest person.`{ preview: boolean, users: object[], users.harvestUserId: string, users.email: string, users.name: string, users.active: boolean, users.status: "existing" \| "creatable" \| "created" \| "not-selected" \| "ambiguous" \| "no-email", users.personId: string (uuid) \| null, created: integer, withoutHarvestAccount: object[], withoutHarvestAccount.personId: string (uuid), withoutHarvestAccount.name: string, withoutHarvestAccount.email: string }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Operator security ### GET /api/v1/operator-security List your operator passkeys and approval status - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Own keys; never public keys or challenges.`{ ready: boolean, passkeys: object[], passkeys.id: string (uuid), passkeys.name: string, passkeys.approved: boolean, passkeys.createdAt: string (date-time) }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422, 429. ### POST /api/v1/operator-security/authentication-options Request a one-use passkey confirmation for a specific operator action - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)**: opaque — see the OpenAPI document **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Session-bound challenge, expires after five minutes; user verification required.`{ challengeId: string (uuid), options: object, options.challenge: string, options.rpId: string, options.timeout: number, options.userVerification: "required" \| "preferred" \| "discouraged", options.allowCredentials: object[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422, 429. ### POST /api/v1/operator-security/passkeys Finish registering an operator passkey with required user verification - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `challengeId` | `string (uuid)` | yes | | | `response` | `object` | yes | | | `response.id` | `string` | yes | | | `response.rawId` | `string` | yes | | | `response.type` | `"public-key"` | yes | | | `response.clientExtensionResults` | `object` | yes | | | `response.authenticatorAttachment` | `"platform" \| "cross-platform"` | no | | | `response.response` | `object` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Passkey registered. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422, 429. ### DELETE /api/v1/operator-security/passkeys/{id} Remove your operator passkey after fresh confirmation; keeps the last approved key - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `challengeId` | `string (uuid)` | yes | | | `response` | `object` | yes | | | `response.id` | `string` | yes | | | `response.rawId` | `string` | yes | | | `response.type` | `"public-key"` | yes | | | `response.clientExtensionResults` | `object` | yes | | | `response.authenticatorAttachment` | `"platform" \| "cross-platform"` | no | | | `response.response` | `object` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Passkey removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422, 429. ### POST /api/v1/operator-security/registration-options Prepare an operator passkey; an existing approved key must authorize additional keys - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `proof` | `object` | no | | | `proof.challengeId` | `string (uuid)` | yes | | | `proof.response` | `object` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | First key remains pending until manually approved.`{ challengeId: string (uuid), options: object, options.challenge: string, options.rp: object, options.user: object, options.pubKeyCredParams: object[], options.timeout: number, options.attestation: "none" \| "indirect" \| "direct" \| "enterprise", options.authenticatorSelection: object, options.excludeCredentials: object[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422, 429. ## Personio ### GET /api/v1/personio-attendance-export Whether the Personio attendance export is active Read from the sync service: active means the target that writes attendances exists and is enabled. Readable by every member; turning it on or off is an owner’s or admin’s. **404** when this installation has no sync service configured; **503** when it cannot be reached. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PersonioAttendanceExport` | application/json | The state. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422, 503. ### PUT /api/v1/personio-attendance-export Activate the Personio attendance export Creates or replaces this organization’s Zynkey graph that writes tracked time to Personio as `WORK` attendances: one attendance per person and local day (the organization’s time zone), starting at the day’s earliest entry and lasting the sum of its entries, with local times and no offset — on the minute, as Personio keeps them: the start cut to its minute and the duration rounded to whole minutes, so a day’s exported working time differs by at most 30 seconds, while its start can lie up to a minute before the day’s earliest entry. Only time tracked on or after `exportFrom` (a local day, read in the organization’s time zone) is ever read; earlier time is never pulled in, and Timjami stores nothing of this day — every activation must state it again, and a later activation with a different day neither re-reads earlier time nor removes what Personio already has. A day whose rounded block would end at or past midnight is not written and shows as an error in Zynkey. Persons are matched through a Zynkey mapping that someone confirms there; a person without a confirmed counterpart is held back. Idempotent: repeating it converges on the same graph and re-enables it. Timjami mints the access token Zynkey reads with — for the caller, with `thymian:read` and `thymian:persons.read` — only after Personio accepted the credentials, forwards it to Zynkey, and revokes every earlier one of this organization once the activation succeeded; it is never returned, and never listed among the caller’s own access tokens. The caller must read everyone’s time on every project, private ones included (owner or admin), since the token reads as they do. The Personio client credentials become this organization’s OAuth client in Zynkey and are never stored, logged or returned by Timjami. The organization’s own name travels with them: every resource of this graph is labelled with it in the sync service, which serves every organization and whose ids name none of them. **422** on `exportFrom` when it cannot be resolved in the organization’s time zone — which is also what an organization time zone that names no zone answers, since the day is resolved in it before anything else, and nothing is written then; **409** when Zynkey refuses a write — including one against a configured scheduler id it does not know — nothing further is written; **422** on `personioClientSecret` when Personio issues no token for them — no Timjami token is minted then. That check reaches Personio only where a token has to be minted, which is the first activation of an organization: replacing the credentials of a client that already holds a live token answers with that token without asking Personio, so wrong replacements answer **200** and show at the next refresh instead; **503** when Zynkey is unreachable. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personioClientId` | `string` | yes | The client id of a Personio API credential (Personio: Einstellungen → Integrationen → API-Zugangsdaten). | | `personioClientSecret` | `string` | yes | Its client secret. Passed to the sync service and never stored, logged or returned by Timjami. Sending it again replaces it; Personio checks a replacement only at the next sync. | | `exportFrom` | `string (date)` | yes | The first day whose tracked time is exported, read in the organization’s time zone. Time before this day is never sent to Personio. Timjami stores nothing of this field — it is converted and forwarded to the sync service on every activation, so changing it later neither re-exports earlier time nor removes what Personio already has. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PersonioAttendanceExport` | application/json | Activated. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422, 503. ### DELETE /api/v1/personio-attendance-export Deactivate the Personio attendance export Disables the Zynkey target and readers of this organization’s export, and revokes the access token Timjami minted for it — whoever activated it, and also when Zynkey fails. Connections and the person mapping stay, so activating again resumes with a new token; attendances already written to Personio stay too. **204** also when the export was never activated. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Deactivated, or never activated. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422, 503. ### GET /api/v1/personio-person-mappings Read Personio person assignments and email suggestions Session and organization.write required. Reads every page from the active organization’s sync-service mapping set; no mapping state is stored in Timjami. Returns identity fields only. Suggestions never confirm an assignment. openCount includes every known Timjami person without a confirmed live counterpart, using the export’s resolution rule. Before the people readers have run, the list can be empty. 404 if the integration has not been provisioned; 503 on an incomplete or unavailable upstream response. - Scope: none beyond authenticating - Credentials: session only **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PersonioPersonMappings` | application/json | The complete person mapping list. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422, 503. ### PUT /api/v1/personio-person-mappings/{id} Confirm, change or revoke one Personio person assignment Session and organization.write required. The id names a mapping item in the active organization’s Timjami source. Both persons must belong to this organization’s mapping set. A changed assignment is revoked and replaced atomically in Zynkey; another person’s assignment is never displaced. expectedAssignment protects against a stale edit. A conflict requires reloading. Revocation stops future resolution; it does not delete attendances already written. The sync service schedules affected items for reprocessing. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personioPersonId` | `string (uuid) \| null` | yes | The Personio mapping item to confirm, or null to revoke. | | `expectedAssignment` | `object \| null` | yes | The assignment and version displayed to the administrator, or null for an unassigned person. | | `expectedAssignment.id` | `string` | yes | | | `expectedAssignment.version` | `integer` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Assignment updated in the sync service. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422, 503. ## Persons ### GET /api/v1/persons List persons Everybody the active organization knows as a person — with or without a login, current or former — keyed by the same `personId` time entries, absences and reports carry. The durable, machine-readable roster an integration reads to map Timjami people onto its own. **Scope.** Requires `thymian:persons.read` and nothing wider: a token minted for this alone cannot read customers, projects, tasks, time, or money. **Pagination.** Keyset, in creation order, oldest first — never by name, so a rename does not move anybody between pages. `limit` is 1–100, default 50. Pass the previous page’s `nextCursor` back unchanged to continue; `null` means the page was the last one. **Completeness.** Without `status`, a walk to the end returns every person of the organization. Nobody disappears for losing access: `status` turns `inactive` instead, so an integration can tell a deactivation from a person it has never seen. **Rate limiting.** Governed by the same per-credential API rate limit as every other `/api/v1` route; no endpoint-specific limit applies. - Scope: `thymian:persons.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `limit` | `integer` | no | At most 100 per page. | | `cursor` | `string` | no | The previous page's `nextCursor`, passed back unchanged. Names the row it left off on, so the set can be walked to completion without skipping or repeating anybody. | | `status` | `"active" \| "inactive"` | no | Only persons with this `status`. Omit it to walk every person of the organization — the complete set a mapping integration compares against. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | One page of persons, in stable creation order.`{ data: Person[], nextCursor: string \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Planning ### PUT /api/v1/planning/allocations Plan one person’s hours for one customer, budget or project in one week Writes one cell, addressed by its natural key — person, target and ISO week. Exactly one of `projectId`, `customerId` or `budgetId` is required, together with `weekStartDay` (a Monday). There is no cell-creation `POST` that could 409 on a repeat and no `PATCH` that could move a row to another person or target: a cell in a grid is written by its coordinates, and this endpoint speaks the same way. A second `PUT` on the same key replaces `plannedSeconds` and `note` outright. `plannedSeconds: 0` **removes the allocation** and answers `{ "allocation": null }` — „nothing planned" is a missing row, never a row saying nothing (invariant 70, the same choice a budget adjustment makes). Any other value must be a whole number of seconds, at most 168 hours. A **positive** `plannedSeconds` is refused as **422** for a `weekStartDay` that is not a Monday, and for a `personId` that does not name a person of the active organization who still holds a membership — that reads exactly like a `personId` naming nobody at all, so a colleague from another organization cannot be told apart from a typo. Refused as **404** for a project, customer or budget target that is missing, foreign or inaccessible. Refused as **409** while the target or its customer is archived, or the budget agreement does not cover the week’s Thursday month: planning new hours on it is „prevents new booking" (invariant 54) applied to intent, and any allocation already stored on it stays visible either way. For a valid cell key, `plannedSeconds: 0` bypasses membership, target availability, archive and budget-span checks and answers **200** either way — a removal only restores the state that held before the row existed, so refusing one would strand an intent rather than protect anything. It is what clears a departed colleague's weeks (a plan outlives the account since ADR-2026-09-07-18ZRU), and a week planned on a target that has since been archived. A `personId`, target id and week that match no row remove nothing and still answer `{ "allocation": null }`. The same choice `budgets.removeAdjustment` makes. `createdByPersonId` is stamped once, from the caller, on the write that first inserts the row; `updatedByPersonId` is stamped on every write, insert included. Neither is the `personId` in the request body — that names the person being planned for, who may be someone else entirely. - Scope: `thymian:planning.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Who this plans for. Must be a person of the active organization who still holds a membership. | | `projectId` | `string (uuid) \| null` | no | The project target. Keep this field for compatibility with existing clients. | | `customerId` | `string (uuid) \| null` | no | The direct customer target. Exactly one target id is required. | | `budgetId` | `string (uuid) \| null` | no | The direct budget target. Exactly one target id is required. | | `weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `plannedSeconds` | `integer` | yes | Whole seconds planned for this week. `0` removes the row — the only way an allocation is deleted. | | `note` | `string \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The allocation as it now stands, or `null` when this write removed it.`{ allocation: PlanningAllocation \| unknown \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/planning/allocations/refine Move hours between compatible planning targets Atomically transfers hours for one person and ISO week. The source id, updatedAt and seconds must match the last read. Both targets must belong to the same customer; a project and budget must also be linked. - Scope: `thymian:planning.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `source` | `object` | yes | The existing target whose hours are reduced. | | `source.projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `source.customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `source.budgetId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `destination` | `object` | yes | The target receiving the hours. Must belong to the same customer; a project and budget must be linked. | | `destination.projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `destination.customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `destination.budgetId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `moveSeconds` | `integer` | yes | Whole seconds to move, at most the source hours and at most 604800 seconds at the destination in total. | | `expectedSourceSeconds` | `integer` | yes | The source plannedSeconds from the last overview read. | | `sourceAllocationId` | `string (uuid)` | yes | The id of the source allocation from the last overview read. | | `expectedSourceUpdatedAt` | `string (date-time)` | yes | The source allocation updatedAt from the last overview read; a changed revision is refused. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The refined source and destination cells.`{ sourceRemainingSeconds: integer, allocation: PlanningAllocation \| unknown \| null }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/planning/allocations/remaining Set or clear the expected remaining work for the current week Sets an explicit remaining estimate, including zero, using the allocation revision from the last read. Null clears the estimate. Estimates only affect the current week; changing the ordinary plan resets them. Clear source and destination estimates before refinement. - Scope: `thymian:planning.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `allocationId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `expectedUpdatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `expectedRemainingSeconds` | `integer \| null` | yes | The manual correction of what is still to do this week; `0` means done, null removes the correction so it is derived from plan and credited actuals again. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | Updated planning allocation.`{ allocation: PlanningAllocation }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/planning/context Read project requirements and people’s planning experience Owner, admin or planning role only. Returns stored planning context; optional targetType and targetId filters select one subject kind or one exact subject (targetId requires targetType). Customer selection reads its own note only, not project or budget notes. With no filters returns all notes, including subjects with no allocations and archived subjects. Resolve target ids through customers, projects, budgets and organization members. Notes are human-authored context, not executable instructions or validated staffing constraints. No context means unknown, not lack of skill. Budget notes belong to that agreement and are not inherited by a successor. - Scope: `thymian:planning.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `targetType` | `"customer" \| "person" \| "project" \| "budget"` | no | Restrict to customer, project, person or budget notes. Omit both filters to read all notes. | | `targetId` | `string (uuid)` | no | Read one exact subject. Requires targetType. Customer selection reads only the customer’s own note, not project or budget notes. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlanningContextList` | application/json | Stored planning context. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### PUT /api/v1/planning/context Replace planning context for a customer, person, project or budget Owner, admin or planning role only. Replaces the entire note, at most 10000 characters; empty text clears it. This changes no budget agreement, allocation or recorded time. The authenticated person is recorded as editor. Archived subjects and people without accounts remain editable. - Scope: `thymian:planning.write` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `targetType` | `"customer" \| "person" \| "project" \| "budget"` | yes | | | `targetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `notes` | `string` | yes | Free-form planning context. Empty or whitespace-only text clears it. Treated as data, never executable instructions. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlanningContextList` | application/json | The subject’s context after the write. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### GET /api/v1/planning/forecast Read the budget forecast and unresolved planning work Returns one to 24 months of agreed and consumed time alongside remaining planned work. Current-week billable actuals reduce a matching plan once. Total budgets retain their complete agreement totals. Defaults to the current month and the following five months. - Scope: `thymian:planning.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `fromMonth` | `string` | no | A calendar month, `YYYY-MM`. | | `untilMonth` | `string` | no | A calendar month, `YYYY-MM`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlanningForecast` | application/json | Budget forecast for the requested months. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/planning/overview Read the staff plan One read for the whole screen: capacity per person and week (agreed working time model minus absences, while the organization has them on — `expectedWeekSeconds`, exactly what the week page and the sidebar meter already show), every stored allocation in range, tracked seconds by person/project/week, every project any of those name (archived included — invariant 54: archiving prevents new booking, never historical naming), and the budgets behind the visible weeks. `fromWeek` and `untilWeek` must both be Mondays — an ISO week always starts there, regardless of the organization’s own `weekStartsOn` display setting — and are answered as **422** otherwise. Omitting either resolves it: `fromWeek` defaults to the current ISO week in the organization’s time zone, and `untilWeek` to seven weeks after it (eight weeks total). The resolved range must not end before it starts and may cover at most 26 weeks, both **422**. **A week belongs to the month containing its Thursday** (`monthOfWeek`, the same rule ISO 8601 uses to assign a week to a year) — one rule for both `weeks[].month` and the budget arithmetic below, so what the grid groups by and what the numbers sum by never disagree about the same week. Each budget month carries agreed, adjusted and consumed seconds plus original planned and open planned seconds. `remainingPlannedSeconds` excludes completed current-week work, matching each billable actual second once, or uses an explicit remaining estimate. `remainingSeconds` is budgeted plus adjustment minus consumed minus remaining planned work. For total budgets the complete agreement totals repeat in each covered month. - Scope: `thymian:planning.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `fromWeek` | `string` | no | The first week to include, inclusive. Defaults to the current ISO week in the organization’s time zone. | | `untilWeek` | `string` | no | The last week to include, inclusive. Defaults to seven weeks after `fromWeek` (eight weeks total). The resolved range may cover at most 26 weeks and must not end before it starts. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `PlanningOverview` | application/json | The staff plan for the requested weeks. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Search ### GET /api/v1/search Search customers, projects and tasks Three separately-capped, separately-ordered lists — never one merged, ranked list, because nothing here computes a relevance score to rank by. Space-separated terms are matched case-insensitively as substrings; every term must occur in the resource context. Customer and project aliases are included. Projects also include their customer name/code, and tasks include their project name. Archived rows are included, distinguished by their own `archivedAt` — archiving governs what may be booked, never what may be named. Within each list, live rows come before archived ones; a project or task under an archived customer or project counts as archived. A private project, and its tasks, are excluded for a viewer not assigned to it, the same visibility `GET /projects` and `GET /tasks` already apply. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `q` | `string` | yes | Space-separated terms are matched case-insensitively as substrings against a resource and its context (customer code/name/aliases, project name/aliases and task name). Every term must match. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `SearchResult` | application/json | Matching customers, projects and tasks. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Time allowances ### GET /api/v1/time-allowances List monthly time allowances Every agreement of the organization, or the ones a filter narrows to — newest span first, so a person and task read as a contract history. A superseded agreement stays listed: it carries a `validUntilMonth` and it is what was agreed for those months. Agreements on an archived project or customer stay listed too, with `projectArchivedAt`/`customerArchivedAt` set — archiving prevents new booking and never hides money or names. Each row carries its corrected months (`adjustments`) and the months a **live** invoice already bills (`billedMonths`) in the same read. A month in `billedMonths` is frozen: the writes below refuse to move it, and the way back is to withdraw that invoice. - Scope: none beyond authenticating - Credentials: session only **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Only this person’s agreements. | | `taskId` | `string (uuid)` | no | Only agreements on this task. | | `projectId` | `string (uuid)` | no | Only agreements on this project’s tasks. | | `customerId` | `string (uuid)` | no | Only agreements on this customer’s projects. | | `coveringMonth` | `string` | no | Only agreements whose span covers this month. Every parameter here narrows; none widens. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The agreements.`{ data: TimeAllowance[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/time-allowances Agree a monthly time allowance For an agreement that *replaces* an existing one, use `POST /time-allowances/supersede` instead: it ends the predecessors and writes the successor in one transaction, which is what one agreed change is. Answers **409** when this person already has an agreement on this task in any of the months covered. Nothing in the application checks that — `time_allowance_no_overlap` is a gist `EXCLUDE` constraint, and it is the authority: an overlap here would not misreport a figure, it would bill the same month twice, and two writers racing into one month both reach the constraint where a preflight query would let one through. Answers **422** for a task that is not billable — an allowance on work nothing prices is not a smaller number but a meaningless one — and **409** while the task, its project or its customer is archived. A `personId` naming nobody of this organization, or a task this viewer may not see, is **404**. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Who the allowance is for. Must be a person of the active organization who still holds a membership. | | `taskId` | `string (uuid)` | yes | The task the months are billed on. Must be **billable** (422 otherwise — an allowance on work nothing prices is not a smaller number, it is a meaningless one) and not archived (409). | | `secondsPerMonth` | `integer` | yes | The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. | | `validFromMonth` | `string` | yes | The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on. | | `validUntilMonth` | `string \| null` | yes | The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October. | | `note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `TimeAllowance` | application/json | The agreement. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### GET /api/v1/time-allowances/{id} One agreement - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeAllowance` | application/json | The agreement. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ### PATCH /api/v1/time-allowances/{id} Correct an agreement For a **typo** — a mistyped number, the wrong start month, a note. A change in what was agreed belongs in `POST /time-allowances/supersede`, so that the months before it keep saying what was true then. Neither the person nor the task can move: that is a different agreement, and moving one would rewrite what a billed month was about. Answers **409** when a live invoice bills any month of this agreement and the patch moves `secondsPerMonth`, or when it moves the span off a billed month — a document froze what those months were worth. The note stays correctable either way. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `secondsPerMonth` | `integer` | no | The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. | | `validFromMonth` | `string` | no | The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on. | | `validUntilMonth` | `string \| null` | no | The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October. | | `note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeAllowance` | application/json | The agreement. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/time-allowances/{id} Delete an agreement nothing billed For one entered by mistake. An agreement that simply *ended* has a `validUntilMonth` and stays listed, because it is what was agreed for those months. Answers **409** as soon as any month of it reached an invoice, live or withdrawn: from that moment it is the record of what a document billed. `invoice_allowance_claim` holds the agreement with `on delete restrict`, so the database refuses exactly those deletions rather than a check having to remember to. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Deleted. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### PUT /api/v1/time-allowances/{id}/adjustments/{month} Adjust one month of an agreement For the month that was different — three weeks of leave in August, or anything else about that single month. `deltaSeconds` is a **delta** on what was agreed and is signed: a shorter month is negative. It must not be zero, because no adjustment is the absence of a row; use `DELETE` for that. **Nothing recomputes this, and nothing derives it from an absence.** A dialog may look at the month’s absences to *suggest* a number, and what is stored is what somebody accepted or typed instead. A proration rule was refused deliberately: it would make an invoice figure move because somebody entered a holiday. Idempotent. A month already adjusted is overwritten, because one month has one answer and correcting it is an edit rather than a second, compensating row. Omitting `note` therefore clears it, the way a `PUT` should. Answers **422** for a month outside the agreement’s span — there is nothing to adjust there, and a month no agreement covers is answered by agreeing one. Answers **409** for a month a live invoice bills: it froze what the month was worth, so moving the figure would make the budget and the Kennzahlen disagree with the document about that month. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `deltaSeconds` | `integer` | yes | A signed delta in whole seconds on this month alone — what somebody decided, never a recomputed total. Negative for a month that was shorter than agreed. Zero is refused: no adjustment is the absence of a row, so use `DELETE`. | | `note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimeAllowanceAdjustment` | application/json | The adjustment. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### DELETE /api/v1/time-allowances/{id}/adjustments/{month} Remove a month’s adjustment The month goes back to exactly what the agreement says. Unguarded by the span, unlike `PUT`: removing an adjustment restores a state that was true before the row existed, so it stays possible for a month the agreement has since stopped covering. A month a live invoice bills is still **409** — removing the delta moves that month’s value exactly as much as writing one does. - Scope: none beyond authenticating - Credentials: session only **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | Removed. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/time-allowances/supersede Replace agreements with the one agreed next Ends every agreement in `replaceIds` the month **before** the successor starts and writes the successor, in one transaction. „Ab Oktober sind es zwei Stunden" is one thing somebody agreed, and the history is the audit trail: there is no revision log here, because supersession *is* one. Answers **422** when a predecessor is for another person or task, starts *after* the successor does, or has already ended earlier — superseding only ever shortens a span and never extends one over months nobody agreed it for. Answers **409** when a live invoice bills a month the shortened span would no longer cover: that month is frozen on a document, and the way back is to withdraw it. - Scope: none beyond authenticating - Credentials: session only **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `replaceIds` | `string (uuid)[]` | yes | The agreements this one replaces. Each is ended the month **before** the successor starts, in the same transaction — „ab Oktober sind es zwei Stunden" is one thing somebody agreed. All must be for the successor’s own person and task. | | `successor` | `object` | yes | The agreement that takes over, written in the same transaction. | | `successor.personId` | `string (uuid)` | yes | Who the allowance is for. Must be a person of the active organization who still holds a membership. | | `successor.taskId` | `string (uuid)` | yes | The task the months are billed on. Must be **billable** (422 otherwise — an allowance on work nothing prices is not a smaller number, it is a meaningless one) and not archived (409). | | `successor.secondsPerMonth` | `integer` | yes | The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. | | `successor.validFromMonth` | `string` | yes | The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on. | | `successor.validUntilMonth` | `string \| null` | yes | The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October. | | `successor.note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `TimeAllowance` | application/json | The successor. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ## Timer ### GET /api/v1/timer Get the caller’s own timer snapshot - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimerSnapshot` | application/json | The current timer snapshot. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### GET /api/v1/timer/events Live timer change notifications for restricted clients The restricted counterpart to `/api/v1/events` for a credential holding `thymian:timer`, or `thymian:read` and `thymian:track` together. It emits `time-entries.changed` and never hint events or domain data. Comment lines are keep-alives. Refetch `/api/v1/timer` after every connection and event because notifications are not durable or replayable. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `string` | text/event-stream | The event stream. Stays open until the client or the server closes it. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403. ### POST /api/v1/timer/forgotten-timer-push-tokens Opt this iPhone installation into forgotten-timer notifications Registers the ordinary APNs device token only after the person explicitly enables the feature in the iOS app. Re-registering the installation rotates its token. See ADR-2026-08-24-15KNJ. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `IosNotificationPushToken` | application/json | The opted-in installation. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/timer/forgotten-timer-push-tokens Opt this iPhone installation out of forgotten-timer notifications - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The installation, if registered, was forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/timer/live-activity-start-token Register the caller’s own Live Activity push-to-start token The per-install token that can start a new Live Activity by push, without the app running — `Activity.pushToStartTokenUpdates`. Re-registering replaces whatever was there before. See ADR-2026-08-19-093A3. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `LiveActivityStartToken` | application/json | The registered push-to-start token. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/timer/live-activity-start-token Forget the caller’s own Live Activity push-to-start token Called when the Live Activity preference is turned off, or on sign-out — so the server can never push-start a new activity for this person again. Idempotent. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The token, if any, was forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/timer/live-activity-tokens Register one running Live Activity’s own update token Observed from `Activity.pushTokenUpdates` — dies with the activity it names. An upsert on `(userId, activityId)`, so re-observing the same activity just refreshes its token. See ADR-2026-08-19-093A3. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `activityId` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `LiveActivityUpdateToken` | application/json | The registered update token. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### DELETE /api/v1/timer/live-activity-tokens/{activityId} Forget one running Live Activity’s own update token Called when that activity ends locally — `LiveActivityService.endCurrent()` — so a stale token cannot receive a later update meant for a different activity. Idempotent. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `activityId` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 204 | — | — | The token, if any, was forgotten. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/timer/start Start or switch the caller’s own timer - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimerSnapshot` | application/json | The updated timer snapshot. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/timer/stop Stop the caller’s own timer Stops the caller’s own running timer and answers the snapshot afterwards, with `stopped`: the entry this request ended, or `null` when nothing was running — which is a success, not an error. See ADR-2026-10-08-00B4A. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `TimerStopResult` | application/json | The updated timer snapshot and the stopped entry. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/timer/widget-push-tokens Register a widget or complication kind for its own WidgetKit push channel Registers, or re-registers, one kind of the caller’s own widgets or complications — `ThymianWidgets` and each of `ThymianWatchComplications`’ three kinds report separately, from `WidgetPushHandler.pushTokenDidChange`. Re-registering an already-known kind just updates its token and when it was last seen. See ADR-2026-08-18-14XZ6. - Scope: `thymian:timer` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"ios" \| "watchos"` | yes | | | `kind` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `WidgetPushToken` | application/json | The registered widget kind. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ## Work schedules ### GET /api/v1/work-schedules List working time model versions Owners and admins see anybody's; a member sees only their own, whether or not they pass `personId`. `personIds` is the roster's bulk read — several people in one call — and names the subject a second way, so it is **exclusive** with `personId` and its deprecated `userId` alias: sending both is a 422 rather than one of the two being quietly ignored. Every stored version travels — resolving a day, a week or a range is client-side arithmetic over the whole list (`@thymian/core`'s `work-schedule.ts`), not a single 'current version'. - Scope: `thymian:read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Whose versions. Only an owner or admin may read anybody’s; a member may only name themselves, and reading without it answers with their own. | | `personIds` | `string` | no | Comma-separated person ids. For reading several people’s versions in one call — the team roster’s case. Exclusive with `personId` and its deprecated `userId` alias: sending both asks two different questions and is a 422. Needs the same permission the moment it names anybody but the caller. Every value must be a person id; an account id here is a 422 rather than a query against a `uuid` column. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `object` | application/json | The versions.`{ data: WorkSchedule[] }` | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/work-schedules Start a new working time model version A new agreement, never a correction — `PATCH` is for a typo in an existing version. Ending an agreement (parental leave, somebody leaving) is a version whose seven days are all `0`, never a deleted row: there is no `validUntilDay`, so the absence of a version means full time. - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Whose working time model. Always named — there is no default to the caller, since only an owner or admin may write this at all. | | `validFromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `kind` | `"weekly" \| "monthly"` | yes | | | `mondaySeconds` | `integer \| null` | yes | | | `tuesdaySeconds` | `integer \| null` | yes | | | `wednesdaySeconds` | `integer \| null` | yes | | | `thursdaySeconds` | `integer \| null` | yes | | | `fridaySeconds` | `integer \| null` | yes | | | `saturdaySeconds` | `integer \| null` | yes | | | `sundaySeconds` | `integer \| null` | yes | | | `monthlySeconds` | `integer \| null` | yes | | | `note` | `string \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `WorkSchedule` | application/json | The new version. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 409, 422. ### PATCH /api/v1/work-schedules/{id} Correct a working time model version A correction, not a move: the person cannot be changed, because that would make this row a different fact while keeping its id. Re-validated as a whole row — changing `kind` without also restating the weekday columns is refused the same way an insert missing them would be. - Scope: `thymian:organization.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `validFromDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `kind` | `"weekly" \| "monthly"` | no | | | `mondaySeconds` | `integer \| null` | no | | | `tuesdaySeconds` | `integer \| null` | no | | | `wednesdaySeconds` | `integer \| null` | no | | | `thursdaySeconds` | `integer \| null` | no | | | `fridaySeconds` | `integer \| null` | no | | | `saturdaySeconds` | `integer \| null` | no | | | `sundaySeconds` | `integer \| null` | no | | | `monthlySeconds` | `integer \| null` | no | | | `note` | `string \| null` | no | | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `WorkSchedule` | application/json | The corrected version. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ## Write-offs ### GET /api/v1/write-offs List write-offs Hours somebody decided will never be billed, newest first — the reason, who waived them and when, and what was waived per rate. Newest first, ordered by `createdAt` then `id`; paginate with `cursor` set to the previous page's `nextCursor`, unchanged — it names both, so two batches written inside one millisecond are not skipped at a page boundary. **Released batches stay listed, marked by `releasedAt`.** A write-off is *taken back*, never deleted: releasing returns its seconds to „Abzurechnen" and leaves the record of what was waived standing. There is no delete endpoint, deliberately — a record a `DELETE` can erase is not one. Amounts are rounded **once per claim** — a claim being one stretch of one entry at one rate — and the per-rate rows are sums of those. That is the write-off’s own rounding rule and is deliberately not an invoice line’s: a write-off predicts no document that could disagree with it. A screen showing these figures has to say so (invariant 19). No per-claim rows here: a batch over an abandoned project can hold hundreds, and the figures a list shows are the per-rate ones. `POST /write-offs` returns the claims it wrote. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Query parameters** | Field | Type | Required | Description | |---|---|---|---| | `status` | `"live" \| "released"` | no | `live` still holds its seconds; `released` was taken back. Omitted means both. | | `limit` | `integer` | no | | | `cursor` | `string` | no | The previous page's `nextCursor`, passed back unchanged. It names the batch's creation instant **and** its id, so two batches written inside one millisecond are not skipped at a page boundary. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `WriteOffList` | application/json | The write-offs. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 422. ### POST /api/v1/write-offs Write off hours that will never be billed Records that these hours are waived, and why. One batch, N **claims** — the same mechanism an invoice uses to say „these seconds are spoken for", so a written-off second cannot afterwards be invoiced and an invoiced one cannot be written off: a gist exclusion constraint says so rather than application code. A 409 means somebody settled some of these minutes in between. The cut, the refusals and the rounding are `POST /write-offs/preview`’s — this is that request with a reason attached, and the response carries the claims it wrote. `reason` is required. A batch with no reason is precisely the unexplained flag this endpoint exists to replace: in the tool Suora used before, the only way to get abandoned hours out of the uninvoiced report was to mark them invoiced — a statement that is simply false, and afterwards indistinguishable from hours that really were billed. The entries become **frozen** in their times and their task, exactly as an invoiced entry is (their notes stay editable, and an owner or admin may still correct them — which then shows under „Abweichungen"). They also stay undeletable and unmergeable, and releasing the write-off does not give that back: the record of what was waived would be worth little if deleting its batch erased it. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | The entries to write off. Their **free** seconds are what gets waived — an entry partly on an invoice contributes only its unbilled rest. A running entry, and a selection with nothing free in it, are refused with 422; an id this organization does not have (or on a project you may not see) is a 404. | | `reason` | `string` | yes | Why these hours will never be billed. **Required**: a batch with no reason is exactly the unexplained flag write-offs exist to replace. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CreatedWriteOff` | application/json | The batch, with the claims it wrote. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/write-offs/{id}/release Take a write-off back Releases the batch and every claim in it, in one transaction: the seconds go back to „Abzurechnen" and are offered to the invoice builder again. **The only way back** — there is no delete endpoint — and the record stays, marked with `releasedAt`, so „was haben wir abgeschrieben" still answers. It returns the seconds and nothing else. The entries stay undeletable and unmergeable, because a claim row still names them; that is the one way a write-off is stricter than an invoice, and it is deliberate. 409 if it has already been released. 404 if this organization has no such write-off. - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Path parameters** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `WriteOff` | application/json | The released write-off. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/write-offs/allowance Write off whole monthly time allowances - Scope: `thymian:invoices.write` - Credentials: session, personal access token, no OAuth (personal access token only) **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `months` | `AllowanceWriteOffMonthRequest[]` | yes | The whole allowance months to write off — never a fraction of one. Already-settled months (by an invoice or another write-off) answer 409; any unpriced share of a month refuses the whole month with 422. | | `reason` | `string` | yes | Why these months will never be billed. Required. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 201 | `CreatedAllowanceWriteOff` | application/json | The batch, with what it waived. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/write-offs/allowance/preview What writing off these allowance months would waive Writes nothing. A month is whole or not at all: any unpriced share refuses the whole month with 422, and an already-settled month (by an invoice or another write-off) is 409. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `months` | `AllowanceWriteOffMonthRequest[]` | yes | The whole allowance months to write off — never a fraction of one. Already-settled months (by an invoice or another write-off) answer 409; any unpriced share of a month refuses the whole month with 422. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `AllowanceWriteOffCut` | application/json | What this selection would waive. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 409, 422. ### POST /api/v1/write-offs/preview What writing off this selection would waive Writes nothing. Returns the claims `POST /write-offs` would store, computed by the same code — so the amount somebody is about to waive is on screen before they confirm it, and the refusals below are met *before* a reason has been typed. A POST rather than a GET because the selection is a list of entry ids, not a handful of query parameters — the same reason `POST /invoices/preview` is one. **What is claimed is the free seconds, cut per rate.** An entry partly on an invoice contributes only its unbilled rest, and an entry whose hours two budgets price differently produces two claims rather than one at an average nothing was ever billed at. **Every claimed second must have a rate**, and unpriced ones are a 422 that names the project and task it is missing on. The waived amount *is* the decision being recorded, so whoever waives unpriced work prices it first — a dated rate rule on the project. The refusal carries the levels as `unpriced` beside the sentence, so a screen can name them. Three more refusals: a **running** entry (422 — it has no duration yet), a selection with **nothing free** in it (422 — every second is already settled), and an entry id this organization does not have or that sits on a project you may not see (404, never 403). Archiving is not filtered anywhere here: writing off is not booking, and the case this exists for is the abandoned — usually archived — project. - Scope: `thymian:invoices.read` - Credentials: session, personal access token, OAuth **Request body (application/json)** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | The entries to write off. Their **free** seconds are what gets waived — an entry partly on an invoice contributes only its unbilled rest. A running entry, and a selection with nothing free in it, are refused with 422; an id this organization does not have (or on a project you may not see) is a 404. | **Answers** | Status | Body | Media type | Meaning | |---|---|---|---| | 200 | `WriteOffCut` | application/json | What this selection would waive. | **Problems** (RFC 9457 `application/problem+json`): 400, 401, 403, 404, 422. ## Schemas ### Absence **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | Inclusive, and never before `fromDay`. | | `secondsPerDay` | `integer \| null` | yes | Whole seconds per covered day. `null` is the whole working day, resolved against the organization’s `hoursPerDay` when a screen asks. | | `note` | `string \| null` | yes | | | `source` | `string` | yes | `manual` for a person; else a system slug. | | `externalId` | `string \| null` | yes | The source system’s own id, so a re-import updates this row instead of adding another. Unique per organization and source. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAtPrecise` | `string` | yes | | ### AbsenceList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `Absence[]` | yes | | | `nextCursor` | `string \| null` | yes | Cursor for the next page, or null when the list is exhausted. | ### AbsenceLogEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `absenceId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `action` | `"created" \| "updated" \| "deleted"` | yes | | | `recordedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `actorPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `actorName` | `string \| null` | yes | | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `secondsPerDay` | `integer \| null` | yes | | | `note` | `string \| null` | yes | | | `source` | `string` | yes | `manual` for a person; else a system slug. | | `externalId` | `string \| null` | yes | | | `reason` | `string \| null` | yes | | ### AbsenceLogHead **Fields** | Field | Type | Required | Description | |---|---|---|---| | `absenceId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `secondsPerDay` | `integer \| null` | yes | | | `note` | `string \| null` | yes | | | `source` | `string` | yes | `manual` for a person; else a system slug. | ### AbsenceRevision **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `absenceId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `action` | `"created" \| "updated" \| "deleted"` | yes | | | `position` | `integer` | yes | | | `recordedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `actorPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `actorName` | `string \| null` | yes | | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `secondsPerDay` | `integer \| null` | yes | | | `note` | `string \| null` | yes | | | `source` | `string` | yes | `manual` for a person; else a system slug. | | `externalId` | `string \| null` | yes | | | `reason` | `string \| null` | yes | | ### AbsenceSubject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Timjami’s own, stable id for this subject. Today every subject is a login, so this is a **better-auth account id** — the value `POST /absences` accepts under its deprecated `userId` alias, not the `personId` that endpoint now prefers. See `kind`. | | `displayName` | `string` | yes | | | `email` | `string` | yes | For mapping against an external system. Always present today. | | `status` | `"active" \| "deactivated"` | yes | `active` while the account can sign in; `deactivated` once it has been banned. Every subject is returned on every page — a mapping integration is expected to keep its own record of one it has already seen rather than have this list forget it. | | `kind` | `"account"` | yes | Always `account` today: every subject is a Timjami login account. Reserved so a future subject with no login is additive. | ### AccessRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `email` | `string (email)` | yes | | | `company` | `string` | yes | | | `message` | `string` | no | | ### AccessRequestAccepted Type: `unknown` ### AccessToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string \| null` | yes | | | `start` | `string \| null` | yes | | | `scopes` | `("thymian:read" \| "thymian:track" \| "thymian:timer" \| "thymian:master-data.read" \| "thymian:master-data.write" \| "thymian:project-members.read" \| "thymian:project-members.write" \| "thymian:project-rates.read" \| "thymian:project-rates.write" \| "thymian:time.read-own" \| "thymian:time.write-own" \| "thymian:time.read-others" \| "thymian:budgets.read" \| "thymian:invoice-drafts.write" \| "thymian:time-allowances.read" \| "thymian:time-allowances.write" \| "thymian:fixed-price.read" \| "thymian:fixed-price.write" \| "thymian:projects.write" \| "thymian:customers.write" \| "thymian:project-icons.write" \| "thymian:budgets.write" \| "thymian:invoices.read" \| "thymian:invoices.write" \| "thymian:billing.read" \| "thymian:billing-proposals.write" \| "thymian:billing-rules.write" \| "thymian:invoice-status.read" \| "thymian:organization.write" \| "thymian:reports.read" \| "thymian:hints.write" \| "thymian:hints.read" \| "thymian:absence-subjects.read" \| "thymian:persons.read" \| "thymian:planning.read" \| "thymian:planning.write")[]` | yes | What this token was granted, intersected with what its owner may do — a scope never widens a role. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `expiresAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `lastRequestAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `requestCount` | `integer` | yes | | ### ActivatePersonioAttendanceExport **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personioClientId` | `string` | yes | The client id of a Personio API credential (Personio: Einstellungen → Integrationen → API-Zugangsdaten). | | `personioClientSecret` | `string` | yes | Its client secret. Passed to the sync service and never stored, logged or returned by Timjami. Sending it again replaces it; Personio checks a replacement only at the next sync. | | `exportFrom` | `string (date)` | yes | The first day whose tracked time is exported, read in the organization’s time zone. Time before this day is never sent to Personio. Timjami stores nothing of this field — it is converted and forwarded to the sync service on every activation, so changing it later neither re-exports earlier time nor removes what Personio already has. | ### AddProjectToBillingRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `fromMonth` | `string` | yes | The month the project joins from: a past, the current, or a future month between the preview’s `earliestMonth` and `latestMonth`. | | `createInvoices` | `boolean` | yes | Also create one draft invoice per ended period that now holds the project’s hours, each billing everything open on that period’s proposal. | ### AdminAccessRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | | `name` | `string` | yes | | | `email` | `string (email)` | yes | | | `company` | `string` | yes | | | `message` | `string \| null` | yes | | | `status` | `"pending" \| "approved" \| "declined"` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `decidedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `inviteCode` | `string \| null` | yes | The code this request’s approval issued, or null while pending or declined. | ### AdminAccessRequestList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `requests` | `AdminAccessRequest[]` | yes | | ### AdminInviteCode **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | | `code` | `string` | yes | | | `label` | `string` | yes | | | `email` | `string (email) \| null` | yes | | | `maxUses` | `integer` | yes | | | `uses` | `integer` | yes | | | `expiresAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `revokedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `status` | `"active" \| "used-up" \| "expired" \| "revoked"` | yes | | | `features` | `OperatorFeature[]` | yes | The features an organization founded with this code starts with, in catalogue order (ADR-2026-09-30-17KCB). | | `redemptions` | `InviteCodeRedemption[]` | yes | | ### AdminInviteCodeList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `codes` | `AdminInviteCode[]` | yes | | ### AgentTokenSums **Fields** | Field | Type | Required | Description | |---|---|---|---| | `input` | `integer` | yes | | | `output` | `integer` | yes | | | `cacheRead` | `integer` | yes | | | `cacheWrite` | `integer` | yes | | | `reasoning` | `integer` | yes | | | `total` | `integer` | yes | By each source’s own counting: Claude Code adds all four kinds; Codex adds input and output, which already contain cached input and reasoning. | ### AgentUsageAttribution Type: `object | object | object | object | object` ### AgentUsageSummary **Fields** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | | | `day` | `string \| null` | yes | The day the figures are narrowed to, or `null` for the whole month. | | `personId` | `string (uuid)` | yes | | | `totals` | `AgentTokenSums` | yes | | | `bySource` | `object[]` | yes | Each agent’s own sums. A kind means one thing only within a source: Codex’s input already contains its cached input, its output its reasoning. | | `bySource.source` | `"claude-code" \| "codex"` | yes | The coding agent a usage key reports for. | | `bySource.tokens` | `AgentTokenSums` | yes | | | `byAttribution` | `object[]` | yes | | | `byAttribution.attribution` | `AgentUsageAttribution` | yes | | | `byAttribution.tokens` | `AgentTokenSums` | yes | | | `byModel` | `object[]` | yes | | | `byModel.source` | `"claude-code" \| "codex"` | yes | The coding agent a usage key reports for. | | `byModel.model` | `string` | yes | | | `byModel.tokens` | `AgentTokenSums` | yes | | | `byDay` | `object[]` | yes | | | `byDay.day` | `string` | yes | | | `byDay.tokens` | `AgentTokenSums` | yes | | | `unattributedPaths` | `object[] \| null` | yes | Your own unattributed sessions by path. `null` whenever `personId` names somebody. | | `unattributedPaths.source` | `"claude-code" \| "codex"` | yes | The coding agent a usage key reports for. | | `unattributedPaths.pathIds` | `string[] \| null` | yes | The ids `PUT /hint-rules` names, parallel to `pathLabels`. | | `unattributedPaths.pathLabels` | `string[] \| null` | yes | | | `unattributedPaths.sessionCount` | `integer` | yes | | | `unattributedPaths.tokens` | `AgentTokenSums` | yes | | ### AllowanceDivergence **Fields** | Field | Type | Required | Description | |---|---|---|---| | `claimId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `invoiceId` | `string (uuid)` | yes | Never `null`, unlike a time-entry claim’s: `invoice_allowance_claim.invoice_id` is `not null`, because a write-off cannot settle a month (invariant 63). | | `invoiceStatus` | `"draft" \| "transferred" \| "withdrawn"` | yes | | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `customerIcon` | `object \| null` | yes | | | `customerIcon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `customerIcon.contentType` | `"image/webp" \| "image/png"` | yes | | | `customerIcon.width` | `integer` | yes | | | `customerIcon.height` | `integer` | yes | | | `customerIcon.byteSize` | `integer` | yes | | | `customerIcon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon.checksum` | `string` | yes | | | `allowanceId` | `string (uuid)` | yes | The agreement this month was billed against — still there whatever has changed, since a billed agreement cannot be deleted (`on delete restrict`). A supersession leaves this pointing at the predecessor, which is what an `outside-span` row usually is. | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `personName` | `string \| null` | yes | | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectName` | `string` | yes | | | `taskName` | `string` | yes | | | `kinds` | `("seconds" \| "outside-span")[]` | yes | What disagrees. `seconds` means the agreement plus this month’s entered delta no longer answers what the invoice froze — somebody corrected the Pauschale, or entered a delta for a month already billed. `outside-span` means the agreement no longer covers the month at all, which is what shortening it or superseding it from an earlier month leaves behind: the document bills a month no agreement states, and every figure derived from the agreement has stopped counting it. The two are never reported together — outside the span there is no figure to disagree with. **Rate and currency drift is not compared here**; that needs a month’s current rate per pot and is a separate question. | | `billed` | `object` | yes | What the claim snapshotted when the invoice was created. | | `billed.seconds` | `integer` | yes | What the invoice froze for this month, and what it still bills. Never recomputed (ADR-0037). | | `billed.currency` | `string` | yes | ISO-4217 currency code. | | `current` | `object` | yes | The agreement as it now stands. | | `current.seconds` | `integer \| null` | yes | What the month is worth today — `seconds_per_month` plus its entered delta, floored at nothing. `null` when the agreement no longer covers the month, which is the `outside-span` case: „this contract says nothing about that month any more" rather than „that month is now worth nothing". | | `current.validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `current.validUntilMonth` | `string \| null` | yes | Inclusive. `null` is an open-ended agreement, so no upper bound at all. | ### AllowanceWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `reason` | `string` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `releasedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `createdByName` | `string \| null` | yes | | | `currency` | `string` | yes | ISO-4217 currency code. | | `seconds` | `integer` | yes | | | `amountMinor` | `integer` | yes | | | `monthCount` | `integer` | yes | | ### AllowanceWriteOffCut **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `seconds` | `integer` | yes | | | `amountMinor` | `integer` | yes | | | `months` | `AllowanceWriteOffMonthCut[]` | yes | | ### AllowanceWriteOffMonthCut **Fields** | Field | Type | Required | Description | |---|---|---|---| | `allowanceId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `seconds` | `integer` | yes | | | `personName` | `string` | yes | | | `projectName` | `string` | yes | | | `taskName` | `string` | yes | | | `amountMinor` | `integer` | yes | | ### AllowanceWriteOffMonthRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `allowanceId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | ### AppInstallationCheckIn **Fields** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | | `app` | `"desktop" \| "cli" \| "ios" \| "browser-extension" \| "raycast"` | yes | | | `version` | `string` | yes | | | `platform` | `string` | yes | | | `architecture` | `string` | yes | | ### ApproveAccessRequestRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `features` | `OperatorFeature[]` | yes | The features an organization founded with the code starts with; `[]` for none. Duplicates are stored once. | ### ApproveAccessRequestResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `code` | `string` | yes | | | `emailSent` | `boolean` | yes | Whether the invite-code email reached the outgoing mail service. The code is issued and visible in the invite-code list either way — a failed send never loses it. | ### AssignExternalDocument **Fields** | Field | Type | Required | Description | |---|---|---|---| | `selector` | `ExternalClaimSelector` | yes | | | `invoice` | `object \| null` | yes | The invoice to attribute these hours to, or `null` to clear the reference — the undo of a wrong repair. | | `invoice.harvestInvoiceId` | `string` | yes | Harvest’s own invoice id — the segment that identifies the document. Opaque; nothing does arithmetic on it. | | `invoice.harvestInvoiceNumber` | `string \| null` | yes | The number a person recognises, or `null` for an invoice Harvest gave none. | ### AssistantTourProgress **Fields** | Field | Type | Required | Description | |---|---|---|---| | `tour` | `"onboarding" \| "menu-tabs"` | yes | Which of Jami’s tours this is: `onboarding`, the tour itself, or `menu-tabs`, his note that the main menu now groups pages as tabs. | | `status` | `"offered" \| "active" \| "completed" \| "dismissed"` | yes | `offered` — this account already used the application when the tour became available to its organization, and is asked rather than walked through it; `active` — under way at `step`; `completed` or `dismissed` — over, restartable. No entry at all means `onboarding` has never been offered, and the web app starts it on arrival; for `menu-tabs` it means there is nothing to tell, since only accounts that knew the earlier menu were offered it. | | `step` | `string \| null` | yes | Set exactly while `status` is `active`. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### AssistantTours Type: `AssistantTourProgress[]` ### AttachExternalReference **Fields** | Field | Type | Required | Description | |---|---|---|---| | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | yes | | | `key` | `string` | yes | The ticket’s identifier. Trimmed; a key containing whitespace is refused rather than collapsed, because no tracker issues one and accepting it would make two references to the same ticket look different forever. | | `url` | `string \| null` | no | Where the ticket lives. Must be `https://`; `http://` and every other scheme are refused, because this is stored once and rendered as a link for the whole organization. | | `title` | `string \| null` | no | A label for the ticket. Trimmed; blank is stored as `null`. | ### AuthProviders **Fields** | Field | Type | Required | Description | |---|---|---|---| | `google` | `boolean` | yes | | ### BatchColorSuggestionOutcome Type: `object | object` ### BatchRefusal **Fields** | Field | Type | Required | Description | |---|---|---|---| | `type` | `string` | yes | | | `title` | `string` | yes | | | `status` | `integer` | yes | The status the single-entry call would have had. | | `detail` | `string` | no | | ### BatchSuggestColors **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerIds` | `string (uuid)[]` | yes | | ### BatchSuggestColorsResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `results` | `BatchColorSuggestionOutcome[]` | yes | | ### BatchTimeEntryResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `results` | `object[]` | yes | | | `results.entryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `results.status` | `"done" \| "unchanged" \| "failed"` | yes | | | `results.refusal` | `BatchRefusal` | no | | | `done` | `integer` | yes | | | `unchanged` | `integer` | yes | | | `failed` | `integer` | yes | | ### BillingAllocation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `kind` | `"entry" \| "allowance" \| "item"` | yes | | | `timeEntryId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `fromSecond` | `integer \| null` | yes | | | `toSecond` | `integer \| null` | yes | | | `allowanceId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `month` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `fixedPriceItemId` | `string (uuid) \| null` | yes | The fixed-price item an `item` allocation names, billed whole on one invoice line; `null` for an entry or an allowance month. Its service month is the month of its last day. | | `serviceMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectName` | `string` | yes | | | `manuallyAssigned` | `boolean` | yes | | | `deferReason` | `string \| null` | yes | | | `deferredAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `resolved` | `boolean` | yes | | | `settledVia` | `"invoice" \| "external" \| "write-off" \| null \| null` | yes | | | `inDraft` | `boolean` | yes | Not resolved, but a stored invoice draft (or a transferred invoice not yet confirmed) already claims part of it — it can no longer be moved, and the draft is what bills it. Which draft is the session-only `GET /billing-proposals/{id}/services`’s answer. | ### BillingAllocationService **Fields** | Field | Type | Required | Description | |---|---|---|---| | `allocationId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personName` | `string` | yes | | | `taskName` | `string` | yes | | | `startedAt` | `string (date-time) \| null` | yes | `null` for an allowance month and for an item. | | `notes` | `string \| null` | yes | | | `fixedPriceItem` | `object \| unknown \| null` | yes | What an `item` allocation bills: its description, kind, amount (in its own currency, which may differ from the organization’s) and service days. `null` for an entry or an allowance month. For an item `personName` lists the people sharing it, empty for an expense, and `taskName` is its agreement, empty when it stands alone. | | `allowanceSeconds` | `integer \| null` | yes | The allowance month’s agreed seconds; `null` for an entry. | | `invoiceId` | `string (uuid) \| null` | yes | The invoice that settled it, or the draft that already claims it; `null` while open, and for an external claim or a write-off. | ### BillingProposal **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `billingRuleId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `ruleName` | `string` | yes | | | `kind` | `"periodic" \| "catch-up"` | yes | | | `periodStartMonth` | `string` | yes | The period’s first month. On a `catch-up` proposal the placeholder `1900-01`, never a date to show: it ends at `periodStartMonth` plus `periodMonthCount` months, and starts with its oldest service. | | `periodMonthCount` | `integer` | yes | | | `dueDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `firstActionableAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `reopenedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `completedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `state` | `"actionable" \| "nothing-to-bill" \| "needs-rate" \| "transfer-pending" \| "complete"` | yes | The user-visible condition, composed at read time. `needs-rate` is reported only on the detail read. | | `unresolvedSeconds` | `integer` | yes | | | `unresolvedAllowanceMonths` | `integer` | yes | | | `unresolvedFixedPriceItems` | `integer` | yes | | | `invoiceIds` | `string (uuid)[]` | no | | | `reopened` | `boolean` | yes | | | `allocationCount` | `integer` | yes | How many service portions it owns. Zero with `state: complete` is a period confirmed empty (`dismiss`), not one that was billed. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### BillingProposalDetail **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `billingRuleId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `ruleName` | `string` | yes | | | `kind` | `"periodic" \| "catch-up"` | yes | | | `periodStartMonth` | `string` | yes | The period’s first month. On a `catch-up` proposal the placeholder `1900-01`, never a date to show: it ends at `periodStartMonth` plus `periodMonthCount` months, and starts with its oldest service. | | `periodMonthCount` | `integer` | yes | | | `dueDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `firstActionableAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `reopenedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `completedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `state` | `"actionable" \| "nothing-to-bill" \| "needs-rate" \| "transfer-pending" \| "complete"` | yes | The user-visible condition, composed at read time. `needs-rate` is reported only on the detail read. | | `unresolvedSeconds` | `integer` | yes | | | `unresolvedAllowanceMonths` | `integer` | yes | | | `unresolvedFixedPriceItems` | `integer` | yes | | | `invoiceIds` | `string (uuid)[]` | no | | | `reopened` | `boolean` | yes | | | `allocationCount` | `integer` | yes | How many service portions it owns. Zero with `state: complete` is a period confirmed empty (`dismiss`), not one that was billed. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `allocations` | `BillingAllocation[]` | yes | | ### BillingProposalListItem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `billingRuleId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `ruleName` | `string` | yes | | | `kind` | `"periodic" \| "catch-up"` | yes | | | `periodStartMonth` | `string` | yes | The period’s first month. On a `catch-up` proposal the placeholder `1900-01`, never a date to show: it ends at `periodStartMonth` plus `periodMonthCount` months, and starts with its oldest service. | | `periodMonthCount` | `integer` | yes | | | `dueDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `firstActionableAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `reopenedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `completedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `state` | `"actionable" \| "nothing-to-bill" \| "needs-rate" \| "transfer-pending" \| "complete"` | yes | The user-visible condition, composed at read time. `needs-rate` is reported only on the detail read. | | `unresolvedSeconds` | `integer` | yes | | | `unresolvedAllowanceMonths` | `integer` | yes | | | `unresolvedFixedPriceItems` | `integer` | yes | | | `invoiceIds` | `string (uuid)[]` | no | | | `reopened` | `boolean` | yes | | | `allocationCount` | `integer` | yes | How many service portions it owns. Zero with `state: complete` is a period confirmed empty (`dismiss`), not one that was billed. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon` | `object \| null` | yes | | | `customerIcon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `customerIcon.contentType` | `"image/webp" \| "image/png"` | yes | | | `customerIcon.width` | `integer` | yes | | | `customerIcon.height` | `integer` | yes | | | `customerIcon.byteSize` | `integer` | yes | | | `customerIcon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon.checksum` | `string` | yes | | ### BillingRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `name` | `string` | yes | | | `status` | `"active" \| "paused"` | yes | | | `pausedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `projectMode` | `"selected" \| "all"` | yes | `selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later. | | `recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `firstPeriodStart` | `string` | yes | A calendar month, `YYYY-MM`. | | `historyMode` | `"separate-periods" \| "collect-history" \| "handle-later"` | yes | The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month. | | `catchUpThroughPeriod` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `hasProposals` | `boolean` | yes | Whether any proposal has been generated for the rule. Until then it can be deleted; afterwards only paused. | | `responsiblePersonIds` | `string (uuid)[]` | yes | The people who receive due/escalation email for this rule. | | `coverage` | `object[]` | yes | | | `coverage.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `coverage.projectName` | `string` | yes | | | `coverage.source` | `"explicit" \| "dynamic-all" \| "history"` | yes | `history`: a closed row over months before the first period that the rule took on at setup, starting at the sentinel month `1900-01`. | | `coverage.validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `coverage.validUntilMonth` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### BillingRuleListItem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `name` | `string` | yes | | | `status` | `"active" \| "paused"` | yes | | | `pausedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `projectMode` | `"selected" \| "all"` | yes | `selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later. | | `recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `firstPeriodStart` | `string` | yes | A calendar month, `YYYY-MM`. | | `historyMode` | `"separate-periods" \| "collect-history" \| "handle-later"` | yes | The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month. | | `catchUpThroughPeriod` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `hasProposals` | `boolean` | yes | Whether any proposal has been generated for the rule. Until then it can be deleted; afterwards only paused. | | `responsiblePersonIds` | `string (uuid)[]` | yes | The people who receive due/escalation email for this rule. | | `coverage` | `object[]` | yes | | | `coverage.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `coverage.projectName` | `string` | yes | | | `coverage.source` | `"explicit" \| "dynamic-all" \| "history"` | yes | `history`: a closed row over months before the first period that the rule took on at setup, starting at the sentinel month `1900-01`. | | `coverage.validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `coverage.validUntilMonth` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon` | `object \| null` | yes | | | `customerIcon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `customerIcon.contentType` | `"image/webp" \| "image/png"` | yes | | | `customerIcon.width` | `integer` | yes | | | `customerIcon.height` | `integer` | yes | | | `customerIcon.byteSize` | `integer` | yes | | | `customerIcon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon.checksum` | `string` | yes | | | `nextPeriod` | `object \| null` | yes | The period the rule proposes next: the one still running, or its first if that lies ahead. `null` while paused. | | `nextPeriod.periodStartMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `nextPeriod.periodMonthCount` | `integer` | yes | | | `nextPeriod.dueDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `latestProposal` | `object \| null` | yes | The proposal whose period ends last, or `null` before the first one. | | `latestProposal.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `latestProposal.kind` | `"periodic" \| "catch-up"` | yes | | | `latestProposal.periodStartMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `latestProposal.periodMonthCount` | `integer` | yes | | | `latestProposal.state` | `"actionable" \| "nothing-to-bill" \| "needs-rate" \| "transfer-pending" \| "complete"` | yes | The user-visible condition, composed at read time. `needs-rate` is reported only on the detail read. | | `latestProposal.allocationCount` | `integer` | yes | | ### BillingRulePeriodPreview **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | | | `periods` | `PricedPreviewPeriod[]` | yes | | | `history` | `PricedPreviewHistory[]` | yes | What `historyMode` makes of the services before the first period. Empty without one, for `handle-later`, and with the first period after the running month. | ### BookableTask **Fields** | Field | Type | Required | Description | |---|---|---|---| | `task` | `object` | yes | | | `task.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `task.name` | `string` | yes | | | `task.displayName` | `string` | yes | Server-selected task label, composed from displayNameParts in the same priority order. | | `task.displayNameParts` | `string[]` | yes | One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. | | `task.isBillable` | `boolean` | yes | | | `task.icon` | `ResourceIcon` | yes | | | `task.iconInheritedFrom` | `VisualProvenance` | yes | | | `project` | `object` | yes | | | `project.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `project.name` | `string` | yes | | | `project.color` | `string \| null` | yes | | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `searchText` | `string` | yes | Canonical customer, project and task names plus customer/project aliases and the customer code. Clients apply the documented word-wise local filter to this value. | ### BreakDismissal **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### BreakPeriod **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### Budget **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `name` | `string` | yes | Named by a person: a budget outlives the composition it started with. | | `validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `validUntilMonth` | `string \| null` | yes | Inclusive end. `null` is open-ended; set when the budget is superseded. | | `budgetedSeconds` | `integer` | yes | The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`. | | `kind` | `"monthly" \| "total"` | yes | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `overflowIntoId` | `string (uuid) \| null` | yes | Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused. | | `overflowIntoName` | `string \| null` | yes | The overflow target’s name, so a screen needs no second lookup. | | `projects` | `object[]` | yes | | | `projects.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.projectName` | `string` | yes | | | `projects.archived` | `boolean` | yes | | | `archivedAt` | `string (date-time) \| null` | yes | Hidden from lists. Different from `validUntilMonth`, which means "was superseded". | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### BudgetAdjustment **Fields** | Field | Type | Required | Description | |---|---|---|---| | `budgetId` | `string (uuid)` | yes | The monthly budget whose adjustment is being changed. | | `month` | `string` | yes | The single calendar month to adjust, formatted `YYYY-MM`; the budget must cover it. | | `adjustmentSeconds` | `integer` | yes | A signed delta in whole seconds, added to the budget’s agreed capacity for this month alone. Use a negative value when earlier work consumed capacity; this is not a replacement total and zero must be removed instead. | | `note` | `string \| null` | yes | Optional explanation for why this month differs. Free text; `null` clears the note. | ### BudgetAlerts **Fields** | Field | Type | Required | Description | |---|---|---|---| | `budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `availableScopes` | `("month" \| "total")[]` | yes | The scopes this budget can carry, in the order to offer them. A `total` budget is one figure for its whole span and has no monthly share; a monthly budget with no end month has no total. A span longer than 24 months has no readable total either. | | `thresholds` | `BudgetAlertThreshold[]` | yes | | ### BudgetAlertThreshold **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `metric` | `"consumption"` | yes | What is measured. Only the budget’s own consumption today; a forecast threshold would be a second value here. | | `scope` | `"month" \| "total"` | yes | What the percentage is taken of: `month` is one calendar month of a monthly budget, `total` the whole agreed span. Which of the two a budget can carry follows from the agreement rather than from preference — read `availableScopes` and offer nothing else. | | `percent` | `integer` | yes | The share of the agreed hours that makes this threshold fire, as a whole percent from 1 to 1000. Free-form within that range rather than a fixed set: 80 and 100 are the usual pair, 50 and 120 are as valid. Above 100 watches an overrun. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `triggers` | `BudgetAlertTrigger[]` | yes | The crossings this threshold has recorded — empty until it fires. A `month`-scope threshold records one per month, and a threshold whose reading fell back below it and rose again records another, so this is a list rather than a boolean. „Reached" means a crossing with a `null` `resolvedAt`. | ### BudgetAlertTrigger **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string \| null` | yes | The month that was watched, or `null` for a `total`-scope threshold. | | `firedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `resolvedAt` | `string (date-time) \| null` | yes | When a later reading fell back below the threshold, `null` while the crossing still stands. A resolved crossing stays readable as history, and its threshold is armed again: the next crossing records and reports itself anew. | | `consumedSeconds` | `integer` | yes | What had been consumed when the threshold was reached. | | `budgetedSeconds` | `integer` | yes | What was agreed for the watched window. | | `adjustmentSeconds` | `integer` | yes | The entered adjustment of the watched window, `0` when there was none. Beside `budgetedSeconds` rather than added into it, like everywhere else (invariant 47). | ### BudgetMonth **Fields** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `budgets` | `object[]` | yes | | | `budgets.budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.name` | `string` | yes | | | `budgets.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.customerName` | `string` | yes | | | `budgets.kind` | `"monthly" \| "total"` | yes | For `monthly`, the figures describe this row’s month. For `total`, they describe the budget’s whole span and repeat identically in every month it covers — a total budget is one figure, and a monthly share of it would be a proration nobody agreed. | | `budgets.validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `budgets.validUntilMonth` | `string \| null` | yes | The span, so a screen can label what a `total` figure covers. | | `budgets.overflowIntoId` | `string (uuid) \| null` | yes | Where hours beyond capacity count instead, when declared. | | `budgets.overflowIntoName` | `string \| null` | yes | | | `budgets.budgetedSeconds` | `integer` | yes | What was agreed, without this month’s adjustment. | | `budgets.adjustmentSeconds` | `integer` | yes | This month’s manual adjustment, `0` when there is none. Beside `budgetedSeconds` rather than added into it: their sum is a figure nobody agreed to, so a screen has to be able to show both parts. | | `budgets.adjustmentNote` | `string \| null` | yes | Why this month was adjusted, when somebody said. | | `budgets.consumedSeconds` | `integer` | yes | | | `budgets.differenceSeconds` | `integer` | yes | Consumed minus budgeted *and* this month’s adjustment. Positive means over. | | `budgets.consumedPercent` | `integer \| null` | yes | How much of `budgetedSeconds` plus `adjustmentSeconds` has been consumed, in whole percent, floored — `null` when that capacity is zero or less. Floored so that it reaches a percentage in the same reading a budget alert threshold on it fires. | | `budgets.projects` | `object[]` | yes | | | `budgets.overlapping` | `boolean` | yes | One of its projects is also in another budget this month, with no overflow order between the two. | | `unbudgeted` | `object[]` | yes | | | `unbudgeted.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `unbudgeted.projectName` | `string` | yes | | | `unbudgeted.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `unbudgeted.customerName` | `string` | yes | | | `unbudgeted.consumedSeconds` | `integer` | yes | | | `overlaps` | `object[]` | yes | | | `overlaps.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `overlaps.projectName` | `string` | yes | | | `overlaps.budgetIds` | `string (uuid)[]` | yes | | ### BudgetReportChannel **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string` | yes | | | `isPrivate` | `boolean` | yes | | | `isMember` | `boolean` | yes | Whether the Timjami app is in the channel. A public channel works either way; a private one only once the app was invited. | ### BudgetReports **Fields** | Field | Type | Required | Description | |---|---|---|---| | `subscriptions` | `BudgetReportSubscription[]` | yes | | | `slackConnected` | `boolean` | yes | Whether this organization has a Slack workspace connected to post into. | | `canManageSlack` | `boolean` | yes | Whether the caller may add, reschedule or remove Slack reports of this budget (`budget.write`). | ### BudgetReportSchedule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `sendWeekdays` | `integer[]` | yes | ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed. | | `sendHour` | `integer` | yes | The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day. | ### BudgetReportSubscription **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `channel` | `"email" \| "slack"` | yes | `email` goes to the caller’s own address and is listed to them alone; `slack` posts to a channel and is listed to everybody who can see the budget. | | `slackChannelId` | `string \| null` | yes | | | `slackChannelName` | `string \| null` | yes | The channel’s name when it was chosen — a label, never used to address the post. | | `createdByName` | `string` | yes | Who set it up. For a Slack report, that person’s current authority is what each post rides on: once they leave or lose `budget.write`, posts stop. | | `sendWeekdays` | `integer[]` | yes | ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed. | | `sendHour` | `integer` | yes | The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `latestDelivery` | `object \| null` | yes | | | `latestDelivery.occurrenceDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `latestDelivery.sentAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `latestDelivery.lastError` | `string \| null` | yes | Why the latest attempt failed, `null` when it went out; always a code, never a provider’s own text. `not-a-member`, `not-authorized` and `budget-hidden` name the configuring person’s lost authority; `unbuildable` a report that could not be built; `slack-not-connected` a missing workspace; `slack-credential-unreadable` a workspace token no configured key decrypts, fixed by reconnecting. `email: unreachable`, `email: rejected` and `email: not-configured` are the mail provider’s answer; `slack: ` followed by Slack’s own code is its refusal — `slack: missing_scope` a workspace connected before reports existed, which must be reconnected once — and `slack: unreachable` a Slack that was never reached. A value starting `outcome-unknown` is a report that may have gone out: a Slack post, whose day is not retried, or a mail the provider already holds a request for, whose retry it deduplicates. | ### BudgetTrajectories **Fields** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | The organization’s current month. | | `today` | `string` | yes | Today, in the organization’s time zone. | | `monthWorkdays` | `integer` | yes | Monday-to-Friday days in `month`. | | `elapsedMonthWorkdays` | `integer` | yes | Monday-to-Friday days in `month` up to and including `today`. | | `budgets` | `BudgetTrajectory[]` | yes | | ### BudgetTrajectory **Fields** | Field | Type | Required | Description | |---|---|---|---| | `budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `kind` | `"monthly" \| "total"` | yes | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `periodFirstDay` | `string` | yes | The month for `monthly`; the whole span for `total`. | | `periodLastDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `basis` | `"plan" \| "pace"` | yes | `plan` when work is planned against the budget in its period: consumed plus remaining planned work. `pace` otherwise: the average per elapsed working day, carried over the remaining ones. Always `pace` while the organization has planning switched off. | | `status` | `"on-track" \| "running-out" \| "left-over" \| "too-early"` | yes | `running-out` or `left-over` when the forecast deviates from capacity by at least `toleranceSeconds`; `too-early` for a `pace` forecast before the third working day. | | `periodWorkdays` | `integer` | yes | | | `elapsedWorkdays` | `integer` | yes | Today included. | | `capacitySeconds` | `integer` | yes | Agreed plus this month’s adjustment, as in `differenceSeconds`. | | `consumedSeconds` | `integer` | yes | | | `targetSeconds` | `integer` | yes | Capacity × elapsed working days ÷ the period’s working days. | | `remainingPlannedSeconds` | `integer \| null` | yes | The shared forecast’s remaining planned work; null for `pace`. | | `pacePerWorkdaySeconds` | `integer` | yes | | | `forecastSeconds` | `integer` | yes | | | `deviationSeconds` | `integer` | yes | Forecast minus capacity. Positive runs out, negative is left over. | | `toleranceSeconds` | `integer` | yes | The larger of 10 % of capacity and eight hours. | | `exhaustsOnDay` | `string \| null` | yes | For `running-out`: the working day the forecast reaches capacity. Null when it already has. | | `requiredPerWorkdaySeconds` | `integer \| null` | yes | For `left-over`: seconds per remaining working day that would use the rest. | ### BudgetTrajectoryDetail **Fields** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `today` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `trajectory` | `BudgetTrajectory` | yes | | | `days` | `object[]` | yes | Every working day (Monday to Friday) of the period. | | `days.day` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `days.targetSeconds` | `integer` | yes | Capacity spread evenly over the working days, up to this one. | | `days.consumedSeconds` | `integer \| null` | yes | Consumed up to and including this day, by the day its entries started; null after today. The last elapsed working day equals `trajectory.consumedSeconds`. | | `days.forecastSeconds` | `integer \| null` | yes | The forecast from today to the end of the period at its own daily rate; null before today and while `too-early`. | ### CapacityAgreement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `validFromDay` | `string` | yes | Inclusive first day this version holds. The next version ends it. | | `kind` | `"share" \| "fixed"` | yes | share = a percentage of contracted time, which follows a contract change by itself. fixed = an absolute sold week, which survives one by itself. | | `sharePercent` | `string \| null` | yes | kind=share only: percentage of contracted time sold, "0.00" to "100.00". | | `soldSecondsPerWeek` | `integer \| null` | yes | kind=fixed only: the sold week itself, in whole seconds. | | `employment` | `"internal" \| "external"` | yes | internal (employed) or external (freelancer). | | `inTeamMetric` | `boolean` | yes | Whether this person’s hours are in the group whose billable quota is measured. | | `note` | `string \| null` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### CapacityAgreements **Fields** | Field | Type | Required | Description | |---|---|---|---| | `agreements` | `CapacityAgreement[]` | yes | | ### ChangeBillingRuleCoverage **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectMode` | `"selected" \| "all"` | yes | `selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later. | | `projectIds` | `string (uuid)[]` | yes | Required, non-empty, when `projectMode` is `selected`; must be empty for `all`. | | `effectiveFromPeriod` | `string` | yes | A future period. Existing proposals before it keep their prior coverage. | ### ChangePersonioPersonMapping **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personioPersonId` | `string (uuid) \| null` | yes | The Personio mapping item to confirm, or null to revoke. | | `expectedAssignment` | `object \| null` | yes | The assignment and version displayed to the administrator, or null for an unassigned person. | | `expectedAssignment.id` | `string` | yes | | | `expectedAssignment.version` | `integer` | yes | | ### CollectorKey **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string \| null` | yes | | | `start` | `string \| null` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lastRequestAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `requestCount` | `integer` | yes | | ### ColorSuggestion **Fields** | Field | Type | Required | Description | |---|---|---|---| | `hex` | `string` | yes | | | `name` | `"Vibrant" \| "DarkVibrant" \| "LightVibrant" \| "Muted" \| "DarkMuted" \| "LightMuted"` | yes | node-vibrant's own swatch name — stable, not translated. | | `population` | `integer` | yes | How many pixels of the icon this swatch covers; the ranking key. | ### ColorSuggestions **Fields** | Field | Type | Required | Description | |---|---|---|---| | `suggestions` | `ColorSuggestion[]` | yes | | | `best` | `ColorSuggestion \| unknown \| null` | yes | | ### CompanyMetricMonth **Fields** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `fromDay` | `string` | yes | The period's first day, organization-local. | | `untilDay` | `string` | yes | The period's last day, inclusive. | | `complete` | `boolean` | yes | False for the period the request falls in: its figures cover the days so far, not a whole period, and are not comparable with the finished ones below them. | | `fte` | `number` | no | Full-time-equivalent sold capacity over this period alone, resolved over its own days so a contract change lands on the right rows. Measured against a full-time week rather than the period's own weekday count, so a weekly working-time model reads the same figure in February as in January while it is unchanged; time agreed per month is still spread over that month's own working days, which a shorter month divides differently. Absent below `report.readOthers`. | | `catchAllRate` | `number \| null` | yes | Percent of the measured group's tracked seconds on catch-all tasks, or null where it tracked nothing. | | `billableRate` | `number \| null` | yes | Percent of the measured group's tracked seconds that is value-creating — invoiceable work plus productive work the organization cannot invoice. | | `billableRateDeviation` | `number \| null` | yes | How far `billableRate` sits outside the target corridor, in percentage points: negative below the floor, positive above the ceiling, exactly 0 inside it. Null without a rate or without targets. | | `revenuePerFteMinor` | `integer \| null` | yes | Minor units of recorded revenue per FTE of this period’s capacity. Counts every internal person and excludes freelancers. | | `revenueMinor` | `integer` | no | The undivided total the figure above comes from, in minor units. Absent below `report.readOthers`. | | `targetRevenueMinor` | `integer \| null` | no | What the per-FTE target for this period asks of its capacity: `targets.revenuePerFteMinor` times `fte`, rounded once. Null without targets, absent below `report.readOthers`. | | `revenueDeviationMinor` | `integer \| null` | yes | `revenueMinor` minus `targetRevenueMinor` — positive above the target, negative below it. Null without targets. | | `measuredSeconds` | `integer` | no | The measured group's tracked seconds, the denominator both rates share. Absent below `report.readOthers`. | | `warnings` | `object` | no | Ways a figure could be quietly understated, or the capacity denominator too generous. None makes one wrong; each is worth saying on screen. Absent entirely for a caller without `report.readOthers`, because all but one of these fields name colleagues — which is not the same as an object whose fields are all empty. | | `warnings.unratedSeconds` | `integer` | yes | Billable seconds no rate priced, among the people revenue counts. Reported rather than valued at zero, so the revenue figure is missing them. | | `warnings.unclassifiedPersonIds` | `string (uuid)[]` | yes | People with tracked time in the period this read covers and no classification. They count in no figure. | | `warnings.withoutSchedule` | `string (uuid)[]` | yes | Internal people with no working-time model at all; each resolved to the organization's default model at a full 1.0. | | `warnings.scheduleStartsInWindow` | `string (uuid)[]` | yes | Internal people whose first working-time version begins inside the window, so the days before it fell back to the default full-time model — a new joiner counts for the whole window. | ### CompanyMetricMonths **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `targets` | `object \| null` | yes | What this organization measures itself against over one calendar month, or null where nothing is configured for it. The revenue target is the organization's agreed monthly figure itself — every row here is a month, so nothing is converted. | | `targets.billableRateMin` | `number` | yes | | | `targets.billableRateMax` | `number` | yes | | | `targets.revenuePerFteMinor` | `integer` | yes | | | `months` | `CompanyMetricMonth[]` | yes | Newest first, the running month included and marked `complete: false`. One row per calendar month. | ### CompanyMetrics **Fields** | Field | Type | Required | Description | |---|---|---|---| | `window` | `object` | yes | The four finished weeks the figures cover. Both bounds inclusive, organization-local. | | `window.fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `window.untilDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `previousWindow` | `object` | yes | The same window shifted back one week — what the previous post covered. | | `previousWindow.fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `previousWindow.untilDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `fte` | `number` | no | Full-time-equivalent sold capacity over `window`: every internal person's dated working time, resolved per day and scaled by their capacity share, averaged into a week and measured against a full-time week of the organization's default model. The five weekdays weigh the same, so the period's weekday composition does not enter it; absences never reduce it. Absent below `report.readOthers`. | | `currency` | `string` | yes | ISO-4217 currency code. | | `catchAllRate` | `object` | yes | Percent of the measured group's tracked seconds on tasks flagged as the catch-all. | | `catchAllRate.value` | `number \| null` | yes | | | `catchAllRate.previous` | `number \| null` | yes | | | `catchAllRate.trend` | `"up" \| "down" \| "flat" \| null \| null` | yes | | | `billableRate` | `object` | yes | Percent of the measured group's tracked seconds on billable tasks. | | `billableRate.value` | `number \| null` | yes | | | `billableRate.previous` | `number \| null` | yes | | | `billableRate.trend` | `"up" \| "down" \| "flat" \| null \| null` | yes | | | `revenuePerFte` | `object` | yes | Minor units of recorded revenue per FTE of sold capacity. Counts every internal person and excludes freelancers. | | `revenuePerFte.value` | `number \| null` | yes | | | `revenuePerFte.previous` | `number \| null` | yes | | | `revenuePerFte.trend` | `"up" \| "down" \| "flat" \| null \| null` | yes | | | `targets` | `object \| null` | yes | What this organization measures itself against over four weeks, or null where nothing is configured for it. The revenue target is the organization's agreed monthly figure converted to this window; the rate corridor is unconverted. | | `targets.billableRateMin` | `number` | yes | | | `targets.billableRateMax` | `number` | yes | | | `targets.revenuePerFteMinor` | `integer` | yes | | | `warnings` | `object` | no | Ways a figure could be quietly understated, or the capacity denominator too generous. None makes one wrong; each is worth saying on screen. Absent entirely for a caller without `report.readOthers`, because all but one of these fields name colleagues — which is not the same as an object whose fields are all empty. | | `warnings.unratedSeconds` | `integer` | yes | Billable seconds no rate priced, among the people revenue counts. Reported rather than valued at zero, so the revenue figure is missing them. | | `warnings.unclassifiedPersonIds` | `string (uuid)[]` | yes | People with tracked time in the period this read covers and no classification. They count in no figure. | | `warnings.withoutSchedule` | `string (uuid)[]` | yes | Internal people with no working-time model at all; each resolved to the organization's default model at a full 1.0. | | `warnings.scheduleStartsInWindow` | `string (uuid)[]` | yes | Internal people whose first working-time version begins inside the window, so the days before it fell back to the default full-time model — a new joiner counts for the whole window. | ### CompanyMetricWeek **Fields** | Field | Type | Required | Description | |---|---|---|---| | `weekStartDay` | `string` | yes | The day this week starts on — its identity. Follows the organization's `weekStartsOn` and is therefore not necessarily a Monday, unlike the ISO `weekStartDay` a plan allocation carries. Equal to `fromDay`. | | `fromDay` | `string` | yes | The period's first day, organization-local. | | `untilDay` | `string` | yes | The period's last day, inclusive. | | `complete` | `boolean` | yes | False for the period the request falls in: its figures cover the days so far, not a whole period, and are not comparable with the finished ones below them. | | `fte` | `number` | no | Full-time-equivalent sold capacity over this period alone, resolved over its own days so a contract change lands on the right rows. Measured against a full-time week rather than the period's own weekday count, so a weekly working-time model reads the same figure in February as in January while it is unchanged; time agreed per month is still spread over that month's own working days, which a shorter month divides differently. Absent below `report.readOthers`. | | `catchAllRate` | `number \| null` | yes | Percent of the measured group's tracked seconds on catch-all tasks, or null where it tracked nothing. | | `billableRate` | `number \| null` | yes | Percent of the measured group's tracked seconds that is value-creating — invoiceable work plus productive work the organization cannot invoice. | | `billableRateDeviation` | `number \| null` | yes | How far `billableRate` sits outside the target corridor, in percentage points: negative below the floor, positive above the ceiling, exactly 0 inside it. Null without a rate or without targets. | | `revenuePerFteMinor` | `integer \| null` | yes | Minor units of recorded revenue per FTE of this period’s capacity. Counts every internal person and excludes freelancers. | | `revenueMinor` | `integer` | no | The undivided total the figure above comes from, in minor units. Absent below `report.readOthers`. | | `targetRevenueMinor` | `integer \| null` | no | What the per-FTE target for this period asks of its capacity: `targets.revenuePerFteMinor` times `fte`, rounded once. Null without targets, absent below `report.readOthers`. | | `revenueDeviationMinor` | `integer \| null` | yes | `revenueMinor` minus `targetRevenueMinor` — positive above the target, negative below it. Null without targets. | | `measuredSeconds` | `integer` | no | The measured group's tracked seconds, the denominator both rates share. Absent below `report.readOthers`. | | `warnings` | `object` | no | Ways a figure could be quietly understated, or the capacity denominator too generous. None makes one wrong; each is worth saying on screen. Absent entirely for a caller without `report.readOthers`, because all but one of these fields name colleagues — which is not the same as an object whose fields are all empty. | | `warnings.unratedSeconds` | `integer` | yes | Billable seconds no rate priced, among the people revenue counts. Reported rather than valued at zero, so the revenue figure is missing them. | | `warnings.unclassifiedPersonIds` | `string (uuid)[]` | yes | People with tracked time in the period this read covers and no classification. They count in no figure. | | `warnings.withoutSchedule` | `string (uuid)[]` | yes | Internal people with no working-time model at all; each resolved to the organization's default model at a full 1.0. | | `warnings.scheduleStartsInWindow` | `string (uuid)[]` | yes | Internal people whose first working-time version begins inside the window, so the days before it fell back to the default full-time model — a new joiner counts for the whole window. | ### CompanyMetricWeeks **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `targets` | `object \| null` | yes | What this organization measures itself against over one whole week, or null where nothing is configured for it. The revenue target is the organization's agreed monthly figure at one week — `× 12/52`, the same conversion the four-week window uses at four weeks; the rate corridor is unconverted. | | `targets.billableRateMin` | `number` | yes | | | `targets.billableRateMax` | `number` | yes | | | `targets.revenuePerFteMinor` | `integer` | yes | | | `weeks` | `CompanyMetricWeek[]` | yes | Newest first, the running week included and marked `complete: false`. One row per whole week. | ### ConnectEasybill **Fields** | Field | Type | Required | Description | |---|---|---|---| | `apiKey` | `string` | yes | Easybill's Bearer API key, from the account's own settings. | | `easybillServicePositionId` | `string` | no | The Easybill article's id. | ### ConnectHarvest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `accountId` | `string` | yes | Harvest's numeric account id, from the account's own settings. | | `accessToken` | `string` | yes | A personal access token, from the account's own settings. | | `confirmDifferentEmail` | `boolean` | no | | ### ConnectSevdesk **Fields** | Field | Type | Required | Description | |---|---|---|---| | `apiToken` | `string` | yes | The account's sevdesk API token, from the settings of the sevdesk user. | | `servicePartId` | `string` | no | sevdesk's own id. | | `expensePartId` | `string` | no | sevdesk's own id. | | `contactPersonId` | `string` | no | sevdesk's own id. | | `taxRuleId` | `1 \| 11` | no | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | ### CreateAbsence **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Whose absence. Defaults to the caller; naming somebody else needs owner or admin. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | Inclusive. Equal to `fromDay` for a single day. | | `secondsPerDay` | `integer \| null` | no | Omit or send `null` for whole days. A number is an absolute duration per covered day — 14400 for half of an eight-hour day, 1800 for thirty minutes. | | `fractionOfDay` | `number` | no | A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`. | | `note` | `string \| null` | no | | | `source` | `string` | no | Defaults to `manual`. | | `externalId` | `string \| null` | no | | | `editReason` | `string` | no | Why an absence is being recorded behind the organization’s tracking deadline anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history. | ### CreateAccessToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | How this token is labelled in the list, e.g. what uses it. | | `scopes` | `("thymian:read" \| "thymian:track" \| "thymian:timer" \| "thymian:master-data.read" \| "thymian:master-data.write" \| "thymian:project-members.read" \| "thymian:project-members.write" \| "thymian:project-rates.read" \| "thymian:project-rates.write" \| "thymian:time.read-own" \| "thymian:time.write-own" \| "thymian:time.read-others" \| "thymian:budgets.read" \| "thymian:invoice-drafts.write" \| "thymian:time-allowances.read" \| "thymian:time-allowances.write" \| "thymian:fixed-price.read" \| "thymian:fixed-price.write" \| "thymian:projects.write" \| "thymian:customers.write" \| "thymian:project-icons.write" \| "thymian:budgets.write" \| "thymian:invoices.read" \| "thymian:invoices.write" \| "thymian:billing.read" \| "thymian:billing-proposals.write" \| "thymian:billing-rules.write" \| "thymian:invoice-status.read" \| "thymian:organization.write" \| "thymian:reports.read" \| "thymian:hints.write" \| "thymian:hints.read" \| "thymian:absence-subjects.read" \| "thymian:persons.read" \| "thymian:planning.read" \| "thymian:planning.write")[]` | yes | What the token may do. Required and never defaulted: a token whose permissions nobody chose is the one nobody can reason about later. | | `expiresInDays` | `integer` | no | Optional lifetime in days. Omit for a token that does not expire — revoking it is then the only way it stops working. | ### CreateAllowanceWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `months` | `AllowanceWriteOffMonthRequest[]` | yes | The whole allowance months to write off — never a fraction of one. Already-settled months (by an invoice or another write-off) answer 409; any unpriced share of a month refuses the whole month with 422. | | `reason` | `string` | yes | Why these months will never be billed. Required. | ### CreateBillingRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | The customer this rule bills. | | `name` | `string` | yes | A human-readable name for the rule. | | `projectMode` | `"selected" \| "all"` | yes | `selected` names explicit projects; `all` dynamically covers every project of the customer, including ones created later. | | `projectIds` | `string (uuid)[]` | yes | Required, non-empty, when `projectMode` is `selected`; must be empty for `all`. | | `recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `firstPeriodStart` | `string` | yes | Normalized to a quarter start when `recurrence` is `quarterly`. | | `historyMode` | `"separate-periods" \| "collect-history" \| "handle-later"` | yes | The one-time historical setup choice made when the rule was created. `separate-periods` prepares one proposal per past period with something to bill; `collect-history` prepares one catch-up proposal through `catchUpThroughPeriod`, once it holds something; `handle-later` leaves older services unassigned. Only months no other rule owns are taken on, and a mode other than `handle-later` needs a first period no later than the running month. | | `catchUpThroughPeriod` | `string \| null` | no | Only meaningful, and required, when `historyMode` is `collect-history`. | | `responsiblePersonIds` | `string (uuid)[]` | yes | The people who receive due/escalation email for this rule. | ### CreateBudget **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer. | | `name` | `string` | yes | A human-readable name for the agreement; it remains stable when the project composition changes. | | `validFromMonth` | `string` | yes | The first calendar month covered by the budget, inclusive. | | `validUntilMonth` | `string \| null` | yes | The last calendar month covered by the budget, inclusive. `null` keeps it open-ended. | | `budgetedSeconds` | `integer` | yes | The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`. | | `kind` | `"monthly" \| "total"` | no | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `overflowIntoId` | `string (uuid) \| null` | no | Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused. | | `overflowFromId` | `string (uuid) \| null` | no | Optionally name one live budget of the same customer whose overflow should point to this new budget in the same transaction. This creates a supplementary pot safely; it is refused if that source already overflows elsewhere. | | `projectIds` | `string (uuid)[]` | yes | One or more project IDs covered by the budget. Every project must belong to `customerId`. | ### CreateBudgetAlert **Fields** | Field | Type | Required | Description | |---|---|---|---| | `scope` | `"month" \| "total"` | yes | What the percentage is taken of: `month` is one calendar month of a monthly budget, `total` the whole agreed span. Which of the two a budget can carry follows from the agreement rather than from preference — read `availableScopes` and offer nothing else. | | `percent` | `integer` | yes | The share of the agreed hours that makes this threshold fire, as a whole percent from 1 to 1000. Free-form within that range rather than a fixed set: 80 and 100 are the usual pair, 50 and 120 are as valid. Above 100 watches an overrun. | ### CreateBudgetReportSlack **Fields** | Field | Type | Required | Description | |---|---|---|---| | `slackChannelId` | `string` | yes | The Slack channel id, as `GET /budgets/report-channels` lists it. | | `sendWeekdays` | `integer[]` | yes | ISO weekdays the report is sent on, 1 = Monday … 7 = Sunday, at least one. A day with nothing new still gets its report: it says where the budget stands, not what changed. | | `sendHour` | `integer` | yes | The hour, in the organization’s time zone, from which the day’s report is due — 0 to 23. It goes out within a few minutes after; a day whose hour has passed while the report could not be sent is retried for about 45 minutes and then skipped, never caught up the next day. | ### CreateCapacityAgreement Type: `object | object` ### CreateCollectorKey **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | How this key is labelled in the list, e.g. the machine it lives on. | ### CreateCustomer **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `code` | `string \| null` | no | | | `color` | `string` | no | | ### CreatedAccessToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string \| null` | yes | | | `start` | `string \| null` | yes | | | `scopes` | `("thymian:read" \| "thymian:track" \| "thymian:timer" \| "thymian:master-data.read" \| "thymian:master-data.write" \| "thymian:project-members.read" \| "thymian:project-members.write" \| "thymian:project-rates.read" \| "thymian:project-rates.write" \| "thymian:time.read-own" \| "thymian:time.write-own" \| "thymian:time.read-others" \| "thymian:budgets.read" \| "thymian:invoice-drafts.write" \| "thymian:time-allowances.read" \| "thymian:time-allowances.write" \| "thymian:fixed-price.read" \| "thymian:fixed-price.write" \| "thymian:projects.write" \| "thymian:customers.write" \| "thymian:project-icons.write" \| "thymian:budgets.write" \| "thymian:invoices.read" \| "thymian:invoices.write" \| "thymian:billing.read" \| "thymian:billing-proposals.write" \| "thymian:billing-rules.write" \| "thymian:invoice-status.read" \| "thymian:organization.write" \| "thymian:reports.read" \| "thymian:hints.write" \| "thymian:hints.read" \| "thymian:absence-subjects.read" \| "thymian:persons.read" \| "thymian:planning.read" \| "thymian:planning.write")[]` | yes | What this token was granted, intersected with what its owner may do — a scope never widens a role. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `expiresAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `lastRequestAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `requestCount` | `integer` | yes | | | `token` | `string` | yes | | ### CreatedAllowanceWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `writeOff` | `AllowanceWriteOff` | yes | | | `cut` | `AllowanceWriteOffCut` | yes | | ### CreateDashboard **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | | | `visibility` | `"private" \| "shared"` | no | private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing. | | `layout` | `DashboardLayout` | yes | | ### CreatedCollectorKey **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string \| null` | yes | | | `start` | `string \| null` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lastRequestAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `requestCount` | `integer` | yes | | | `key` | `string` | yes | | ### CreatedProjectAllowanceWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `writeOffId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `cut` | `AllowanceWriteOffCut` | yes | | ### CreatedProjectEntriesWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `writeOffId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `cut` | `WriteOffCut` | yes | | ### CreatedProjectWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entries` | `CreatedProjectEntriesWriteOff` | yes | | | `allowance` | `CreatedProjectAllowanceWriteOff` | yes | | ### CreatedUsageKey **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string \| null` | yes | | | `start` | `string \| null` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lastRequestAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `source` | `"claude-code" \| "codex"` | yes | The coding agent a usage key reports for. | | `device` | `string` | yes | | | `key` | `string` | yes | The key itself. Shown once; only a hash is stored. | ### CreatedWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `writeOff` | `WriteOff` | yes | | | `cut` | `WriteOffCut` | yes | | ### CreateFixedPriceAgreement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | ### CreateFixedPriceItem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `agreementId` | `string (uuid) \| null` | no | Required for a `service`; omit or `null` for a standalone `expense`. | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `kind` | `"service" \| "expense"` | yes | `service` is revenue that people share by weight (a workshop’s day fee) and always belongs to an agreement. `expense` is passed through and counts as no revenue (travel costs); it may stand alone on a project. | | `description` | `string` | yes | | | `quantityHundredths` | `integer` | yes | | | `unit` | `string` | yes | | | `unitPriceMinor` | `integer` | yes | | | `days` | `string[]` | yes | | | `people` | `object[]` | no | At least one for a `service`, each with a weight; none for an `expense`. Each must be a live member of the organization. | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `people.weight` | `integer` | yes | The share of the item’s revenue, relative to the other people. Equal by default. | | `easybillPositionId` | `string \| null` | no | The Easybill article overriding the kind’s own; omit or `null` for the kind’s article. Whether Easybill knows it is answered at the export. | | `editReason` | `string` | no | Why an item is being created on a closed day anyway. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends. | ### CreateInviteCodeRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `features` | `OperatorFeature[]` | yes | The features an organization founded with the code starts with; `[]` for none. Duplicates are stored once. | | `label` | `string` | yes | | | `email` | `string (email)` | no | | | `maxUses` | `integer` | yes | | | `expiresAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### CreateInvoice **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectIds` | `string (uuid)[]` | no | | | `budgetIds` | `string (uuid)[]` | no | The budgets to bill, or empty for every pot. Exactly the pots named, never their overflow chain. Seconds in other pots are reported as `outside-selection` rather than dropped. | | `taskIds` | `string (uuid)[]` | no | The tasks to bill, or empty for every task of the selected projects. Hours on other tasks are reported as `outside-selection`. | | `from` | `string (date-time) \| null` | yes | Inclusive lower bound on the entry start, or null for no lower bound. | | `until` | `string (date-time)` | yes | Exclusive upper bound on the entry start. | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | | `notes` | `string \| null` | no | Free text for the invoice. Never filled from a time entry’s notes, which may be internal. | ### CreateProject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `rules` | `WriteRateRule[]` | no | | | `visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `color` | `string \| null` | no | | ### CreateProjectWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | no | Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. | | `until` | `string` | no | Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. | | `reason` | `string` | yes | Why these hours and months will never be billed. Required, for the same reason `CreateWriteOff.reason` is. | ### CreateProposalInvoice **Fields** | Field | Type | Required | Description | |---|---|---|---| | `notes` | `string \| null` | no | | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | no | Overrides the proposal’s own default grouping for this one invoice. | ### CreateTask **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | no | The billing mode to set. `fixed_price` needs `fixedPriceAgreementId` in the same request or already on the task, and `hourly` and `productive` forbid one. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === "hourly"`), otherwise the write answers 422. | | `fixedPriceAgreementId` | `string (uuid) \| null` | no | The fixed-price agreement this task’s hours are spent on. Its customer must be the task’s customer. `fixed_price` requires one, `hourly` and `productive` forbid one, `internal` may carry one. | | `isBillable` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. Whether hours on this task are priced by the hour. The task is `hourly` by default, which applies only when `billingMode`, `isBillable` and `isProductive` are all omitted; sending `isProductive: true` alone creates a `productive` task. | | `isProductive` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. Derived: `billingMode === "productive"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `isCatchAll` | `boolean` | no | Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | ### CreateTimeAllowance **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Who the allowance is for. Must be a person of the active organization who still holds a membership. | | `taskId` | `string (uuid)` | yes | The task the months are billed on. Must be **billable** (422 otherwise — an allowance on work nothing prices is not a smaller number, it is a meaningless one) and not archived (409). | | `secondsPerMonth` | `integer` | yes | The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. | | `validFromMonth` | `string` | yes | The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on. | | `validUntilMonth` | `string \| null` | yes | The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October. | | `note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | ### CreateTimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | yes | When the work started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time)` | yes | When it ended. May equal `startedAt`, which records an entry of no length; earlier than it is rejected. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `notes` | `string \| null` | no | | | `editReason` | `string` | no | Why hours are being booked into a **closed** tracking period anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the day this entry starts in is at or below its project’s `closedThroughDay` and the request gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other booking. It is kept with the revision this write appends, readable in the entry’s history. | ### CreateUsageKey **Fields** | Field | Type | Required | Description | |---|---|---|---| | `source` | `"claude-code" \| "codex"` | yes | The coding agent a usage key reports for. | | `device` | `string` | yes | The machine the agent runs on, as the key is labelled in the list. | ### CreateWorkSchedule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Whose working time model. Always named — there is no default to the caller, since only an owner or admin may write this at all. | | `validFromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `kind` | `"weekly" \| "monthly"` | yes | | | `mondaySeconds` | `integer \| null` | yes | | | `tuesdaySeconds` | `integer \| null` | yes | | | `wednesdaySeconds` | `integer \| null` | yes | | | `thursdaySeconds` | `integer \| null` | yes | | | `fridaySeconds` | `integer \| null` | yes | | | `saturdaySeconds` | `integer \| null` | yes | | | `sundaySeconds` | `integer \| null` | yes | | | `monthlySeconds` | `integer \| null` | yes | | | `note` | `string \| null` | no | | ### CreateWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | The entries to write off. Their **free** seconds are what gets waived — an entry partly on an invoice contributes only its unbilled rest. A running entry, and a selection with nothing free in it, are refused with 422; an id this organization does not have (or on a project you may not see) is a 404. | | `reason` | `string` | yes | Why these hours will never be billed. **Required**: a batch with no reason is exactly the unexplained flag write-offs exist to replace. | ### Customer **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `aliases` | `string[]` | yes | Alternative names. They are included in resource search and when filtering task pickers. | | `code` | `string \| null` | yes | | | `color` | `string` | yes | | | `colorSource` | `"default" \| "suggested" \| "manual"` | yes | `default` — nobody has touched it. `suggested` — the logo-colour batch action set it. `manual` — a person set it through create or update, and it blocks both the batch action and the suggestion picker’s pre-selection. | | `icon` | `ResourceIcon` | yes | | | `archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### CustomerList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `Customer & object[]` | yes | | | `nextCursor` | `string \| null` | yes | Cursor for the next page, or null when the list is exhausted. | ### CustomerSetup **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customer` | `object` | yes | | | `customer.name` | `string` | yes | | | `customer.aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `customer.code` | `string \| null` | no | | | `easybill` | `object` | no | The Easybill customer to map, read back from Easybill before anything is written. Needs `easybill.map`. When the read fails, everything else is still created and `easybill` in the answer says why the mapping is missing. | | `easybill.easybillCustomerId` | `string` | yes | | | `project` | `object` | no | The first project. | | `project.name` | `string` | yes | | | `project.aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `project.visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `project.hourlyRateMinor` | `integer` | no | The project’s default hourly rate in minor units, as one rule from the beginning. Not together with `rateTemplateProjectId`. | | `project.rateTemplateProjectId` | `string (uuid)` | no | A project whose rates are copied: only the version in force today, as one version from the beginning. Its default rule is copied; a task rule moves to the new task of the same name (case-insensitively) and is left out without one; a person rule is left out for a former member — somebody whose membership ended or whose account was deleted — and kept for a colleague who never had an account; a budget rule never. Everything left out is listed in `droppedRateRules`. 404 when you cannot see the project. | | `project.tasks` | `object[]` | no | The tasks to create. No two may share a name, compared case-insensitively. | | `project.personIds` | `string (uuid)[]` | no | The people to assign. You are assigned anyway, named here or not. | | `billing` | `object` | no | A recurring billing rule for all of the customer’s projects. Needs `invoice.write`. Older hours are left for later — a new customer has none — so the rule produces no proposal before its first period ends and can be deleted until then. | | `billing.name` | `string` | yes | A human-readable name for the rule. | | `billing.recurrence` | `"monthly" \| "quarterly"` | yes | How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct. | | `billing.dueDayOfMonth` | `integer` | yes | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `billing.selectionMode` | `"period-only" \| "cumulative"` | yes | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `billing.defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | yes | The default invoice grouping a proposal generated under this rule freezes. | | `billing.firstPeriodStart` | `string` | no | The first period, normalized to a quarter start when `recurrence` is `quarterly`. Defaults to the current month; a period that has already ended is refused. | | `billing.responsiblePersonIds` | `string (uuid)[]` | no | The people reminded when a proposal is due. Defaults to you. | ### CustomerSetupResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customer` | `Customer` | yes | | | `project` | `Project \| unknown \| null` | yes | | | `billingRule` | `BillingRule \| unknown \| null` | yes | | | `easybill` | `EasybillCustomerMapping & object \| object \| unknown \| null` | yes | `null` when the request named no Easybill customer. | | `droppedRateRules` | `DroppedRateRule[]` | yes | | ### CustomerSetupTemplate **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `visibility` | `"public" \| "private"` | yes | Who may see the project. `public` is the whole organization. | | `tasks` | `object[]` | yes | The project’s tasks that are not archived, by name. | | `tasks.name` | `string` | yes | | | `tasks.billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | yes | How this task’s hours are billed. `hourly` is priced by the hour; `productive` is value-creating work that is never invoiced; `internal` is neither; `fixed_price` is billed through the task’s fixed-price agreement, not by the hour. Prefer this over `isBillable` and `isProductive`, which are derived from it. | | `tasks.isBillable` **(deprecated)** | `boolean` | yes | Deprecated, use `billingMode`. Derived: `billingMode === "hourly"`, so it is false for every other mode, including work that is billed but not by the hour. | | `tasks.isProductive` **(deprecated)** | `boolean` | yes | Deprecated, use `billingMode`. Derived: `billingMode === "productive"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `tasks.isCatchAll` | `boolean` | yes | Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `personIds` | `string (uuid)[]` | yes | The people assigned to the project, without former members — somebody whose membership ended or whose account was deleted. A colleague who never had an account is included. The same people a template copy keeps person rates for. | ### Dashboard **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `visibility` | `"private" \| "shared"` | yes | private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing. | | `layout` | `DashboardLayout` | yes | | | `ownerPersonId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `ownerName` | `string` | yes | The author’s display name, so a shared dashboard says whose it is. | | `isOwn` | `boolean` | yes | Whether the caller owns it — and therefore whether they may change it. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### DashboardLayout **Fields** | Field | Type | Required | Description | |---|---|---|---| | `widgets` | `DashboardWidgetPlacement[]` | yes | The placements, in the order they were composed. At most 100, each with an id no other placement in this layout uses. | ### Dashboards **Fields** | Field | Type | Required | Description | |---|---|---|---| | `dashboards` | `Dashboard[]` | yes | | ### DashboardWidgetPerson **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `name` | `string` | yes | | | `avatarUrl` | `string \| null` | yes | The final profile picture URL: the person's Timjami avatar, otherwise the identity provider's picture. | | `scheduledSeconds` | `integer` | yes | The work schedule demand before absences are applied. | | `absentSeconds` | `integer` | yes | | | `expectedSeconds` | `integer` | yes | Today's target after absences are applied. | | `trackedSeconds` | `integer` | yes | Tracked seconds including a running timer measured at `asOf`. | | `billableSeconds` | `integer` | yes | Of `trackedSeconds`, the seconds on billable tasks — task billability, not whether a rate resolved. | | `hasAbsence` | `boolean` | yes | | | `isRunning` | `boolean` | yes | | | `tasks` | `object[]` | yes | | | `tasks.taskId` | `string (uuid)` | yes | | | `tasks.taskName` | `string` | yes | | | `tasks.projectId` | `string (uuid)` | yes | | | `tasks.projectName` | `string` | yes | | | `tasks.customerId` | `string (uuid)` | yes | | | `tasks.customerName` | `string` | yes | | | `tasks.trackedSeconds` | `integer` | yes | | | `tasks.isRunning` | `boolean` | yes | | ### DashboardWidgetPlacement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Client-assigned identity of this placement, unique within the layout. | | `widget` | `string` | yes | Which widget to render. Opaque to the server — the web app resolves it against its own catalogue and skips what it does not know. | | `x` | `integer` | yes | | | `y` | `integer` | yes | | | `w` | `integer` | yes | | | `h` | `integer` | yes | | | `settings` | `object` | no | How this placement is configured. Opaque to the server exactly like `widget` — checked to be a JSON object of at most 4096 bytes and never for its fields; the web app parses it and falls back to defaults for what it cannot read. | | `title` | `string` | no | A title chosen for this placement, shown instead of the widget’s own. | ### DashboardWidgetResult Type: `object | object | object | object | object | object | object | object | object | object | object | object | object | object | object` ### DashboardWidgets **Fields** | Field | Type | Required | Description | |---|---|---|---| | `asOf` | `string (date-time)` | yes | The single instant running timers were measured at. | | `day` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `currency` | `string` | yes | ISO-4217 currency code. | | `results` | `DashboardWidgetResult[]` | yes | One result per request, in request order. | ### DashboardWidgetsRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `day` | `string` | no | The organization-local day the periods are read for. Defaults to today in the organization's time zone. | | `requests` | `(object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object \| object)[]` | yes | | ### DayProgress **Fields** | Field | Type | Required | Description | |---|---|---|---| | `day` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `trackedSeconds` | `integer` | yes | Finished seconds only — the same rule every other figure here follows. | | `targetSeconds` | `integer` | yes | What this one day was expected to hold, alone — zero for a day nobody works or one an absence covers in full. | ### DeletedTimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `recordedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `actorPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `actorName` | `string \| null` | yes | | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `notes` | `string \| null` | yes | | | `taskName` | `string \| null` | yes | | | `projectName` | `string \| null` | yes | | | `customerName` | `string \| null` | yes | | ### DeleteTimeEntries **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | | | `editReason` | `string` | no | Why hours that are frozen are being changed anyway — a live claim, a closed tracking period, or a day further back than this organization records time for. Applies to every entry of the batch that needs one. An entry that needs it and does not get it is **failed** with a 422 problem carrying `reasonRequired: true`, and the rest of the batch still runs. Ignored for an entry nothing is holding. It is kept with the revision each write appends, readable in that entry’s history. | ### DemoDataCleanup **Fields** | Field | Type | Required | Description | |---|---|---|---| | `deleted` | `DemoDataCounts` | yes | | | `preserved` | `DemoDataCounts` | yes | | ### DemoDataCounts **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customers` | `integer` | yes | | | `projects` | `integer` | yes | | | `tasks` | `integer` | yes | | | `timeEntries` | `integer` | yes | | ### DemoDataStatus **Fields** | Field | Type | Required | Description | |---|---|---|---| | `exists` | `boolean` | yes | | | `createdAt` | `string (date-time) \| null` | yes | | | `counts` | `DemoDataCounts` | yes | | | `canManage` | `boolean` | yes | | ### DismissHint **Fields** | Field | Type | Required | Description | |---|---|---|---| | `dismissed` | `boolean` | yes | true = not worth tracking. false takes the dismissal back. | ### Divergence **Fields** | Field | Type | Required | Description | |---|---|---|---| | `claimId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `kind` | `"invoice" \| "external" \| "write-off"` | yes | What settled these seconds. `invoice` is a Timjami invoice, `external` a document in another system (the Harvest era), `write-off` waived hours. Every live claim is compared, whatever its kind — the owner/admin override can move facts under any of them. See ADR-2026-08-24-11D67. | | `invoiceId` | `string (uuid) \| null` | yes | The Timjami invoice this claim belongs to — `null` for every other kind, which is the same statement as `kind !== "invoice"`. | | `invoiceStatus` | `"draft" \| "transferred" \| "withdrawn" \| null \| null` | yes | | | `externalDocumentRef` | `string \| null` | yes | What the other system calls the document these hours were billed on — for Harvest, `harvest:invoice::`. `null` for every other kind, and for an `external` claim whose source named no document. | | `customerName` | `string` | yes | | | `customerIcon` | `object \| null` | yes | | | `customerIcon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `customerIcon.contentType` | `"image/webp" \| "image/png"` | yes | | | `customerIcon.width` | `integer` | yes | | | `customerIcon.height` | `integer` | yes | | | `customerIcon.byteSize` | `integer` | yes | | | `customerIcon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon.checksum` | `string` | yes | | | `customerId` | `string (uuid)` | yes | The customer `customerName` names — the invoice’s where there is one, the entry’s own chain otherwise. The two are the same row for almost every claim, and where they differ this pair still names one customer. | | `entryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectId` | `string (uuid)` | yes | The project of the entry’s task as it now stands — the **entry’s** chain, never the invoice’s, and where the dated rate rules behind a `rate` divergence are written. | | `projectName` | `string` | yes | | | `taskName` | `string` | yes | | | `kinds` | `("window" \| "task" \| "person" \| "rate" \| "partly-unpriced" \| "currency" \| "over-billed")[]` | yes | Which facts disagree. `rate` means the number over the claimed seconds is no longer the frozen one — a different rate, no rate at all, or two rates over stretches this claim covers (see `rateMixed`). `partly-unpriced` is not a changed number: some of the claimed seconds are priced by nothing today while the rest may still resolve to exactly the billed rate, which is reachable when a rule names one budget and not the next one the hours spill into. `over-billed` means the claim reaches past the entry’s current duration — somebody shortened it after it was billed; it is not reported for an entry that was reopened and is simply still running. `currency` means the organization changed its currency after the invoice froze one. | | `effect` | `"billing" \| "relocation"` | yes | Whether what disagrees changed what the customer was billed, or only where the hours now hang. `relocation` means one thing and only that thing: the task is the **only** fact that disagrees, and the customer these seconds were settled for — `billed.customerId` — is still the one the entry hangs under today. The same hours, at the same rate, in the same currency, for the same person, over the same interval, booked on a different task, which is what restructuring a customer’s projects produces for every invoiced entry. A claim with no invoice of its own is answered the same way rather than refused: an `external` claim and a write-off are compared against the customer of the project the claim froze, so they are classified exactly like an invoice’s claim. Everything else is `billing` — above all a bare task move that carried the hours onto **another customer’s** project, where `billed.customerId` and `current.customerId` then disagree, and a comparison that cannot be made at all, which is a claim with no invoice whose frozen project has since been deleted. A label on a row that is reported either way: nothing is filtered out of this answer, and a client that reads only `kinds` is unaffected. See ADR-2026-09-07-15WGW. | | `billed` | `object` | yes | What the claim snapshotted when the invoice was created. | | `billed.startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `billed.endedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `billed.personId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `billed.rateMinor` | `integer \| null` | yes | What was billed per hour. `null` only for an `external` claim whose source states no rate — an invoice’s and a write-off’s is required. A `null` here is never reported as a `rate` divergence: „the other system did not say" is a gap, not a disagreement. | | `billed.currency` | `string` | yes | ISO-4217 currency code. | | `billed.claimedSeconds` | `integer` | yes | | | `billed.customerId` | `string (uuid) \| null` | yes | The customer these seconds were settled for — the invoice’s own where there is one, otherwise the customer of the project the claim froze. `null` only for a claim with no invoice whose frozen project has since been deleted. Compare it with `current.customerId`: where the two disagree, invoiced hours have moved to another customer’s project, which is always reported as `effect: "billing"`. | | `billed.customerName` | `string \| null` | yes | | | `current` | `object` | yes | The entry as it now stands. | | `current.startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `current.endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `current.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `current.rateMinor` | `integer \| null` | yes | What the seconds this claim bills would be worth now, resolved from the project’s dated rules — the claimed stretch of the entry, not the whole of it. Read off the priced stretches, exactly as the entries list reads an entry: `null` when nothing prices them, and when two budgets price them differently, which `rateMixed` tells apart. A claim one budget prices and another does not keeps that one rate and reports the gap as `partly-unpriced`. | | `current.rateMixed` | `boolean` | yes | True when more than one budget holds the claimed seconds and prices them at two different rates, so there is no single current rate to compare the billed one with — never merely because part of them is unpriced, which is `partly-unpriced`. A claim covering only one budget’s stretch of such an entry has a single rate and is not a divergence. | | `current.isBillable` | `boolean` | yes | | | `current.currency` | `string` | yes | ISO-4217 currency code. | | `current.durationSeconds` | `integer` | yes | | | `current.customerId` | `string (uuid)` | yes | The customer the entry’s task chain names **today**. The row’s top-level `customerId` is the invoice’s wherever there is one, so that a customer’s name and the invoice linked beside it are one customer; where these two disagree, invoiced hours have moved to a different customer’s project and this is the one they arrived at. | | `current.customerName` | `string` | yes | | | `override` | `object \| null` | yes | Why somebody changed these hours after they were billed, most recently — the reason an owner or admin had to state to get past the freeze (`editReason` on `PATCH /time-entries/{id}`). `null` where no override caused this: a rate rule rewritten, the organization’s currency changed, an import that went wrong, or a correction made before reasons were recorded. | | `override.reason` | `string` | yes | | | `override.recordedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `override.actorName` | `string \| null` | yes | | ### DroppedRateRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `reason` | `"budget" \| "task" \| "person"` | yes | `budget`: a budget rule, never copied. `task`: no new task carries its task’s name. `person`: its person is a former member, whose membership ended or whose account was deleted. | | `taskName` | `string \| null` | yes | | | `personId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `personName` | `string \| null` | yes | | | `budgetId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `budgetName` | `string \| null` | yes | | | `hourlyRateMinor` | `integer` | yes | | ### EasybillConnection **Fields** | Field | Type | Required | Description | |---|---|---|---| | `configured` | `boolean` | yes | | | `easybillServicePositionId` | `string \| null` | yes | | | `easybillServiceLabel` | `string \| null` | yes | | | `easybillExpensePositionId` | `string \| null` | yes | | | `easybillExpenseLabel` | `string \| null` | yes | | | `connectedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | ### EasybillCustomerList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.number` | `string \| null` | yes | | | `data.companyName` | `string \| null` | yes | | | `data.firstName` | `string \| null` | yes | | | `data.lastName` | `string \| null` | yes | | | `data.zipCode` | `string \| null` | yes | | | `data.city` | `string \| null` | yes | | | `page` | `integer` | yes | | | `pages` | `integer` | yes | | ### EasybillCustomerMapping **Fields** | Field | Type | Required | Description | |---|---|---|---| | `easybillCustomerId` | `string` | yes | | | `easybillCustomerName` | `string \| null` | yes | | ### EasybillCustomerMappingList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `EasybillCustomerMapping & object[]` | yes | | ### EasybillOutstanding **Fields** | Field | Type | Required | Description | |---|---|---|---| | `openMinor` | `integer` | yes | | | `openCount` | `integer` | yes | | | `overdueMinor` | `integer` | yes | | | `overdueCount` | `integer` | yes | | | `withheldCount` | `integer` | yes | | | `oldestFetchedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | ### EasybillServiceList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.number` | `string \| null` | yes | | | `data.description` | `string \| null` | yes | | | `data.unit` | `string \| null` | yes | | | `data.vatPercent` | `number \| null` | yes | | | `page` | `integer` | yes | | | `pages` | `integer` | yes | | ### EntryWritePolicy **Fields** | Field | Type | Required | Description | |---|---|---|---| | `times` | `"free" \| "reason" \| "locked"` | yes | `startedAt` and `endedAt`. | | `task` | `"free" \| "reason" \| "locked"` | yes | `free` — nothing is in the way. `reason` — allowed, and the save must carry `editReason`. `locked` — refused for this caller whatever they type. | | `notes` | `"free" \| "reason" \| "locked"` | yes | Always `free`: a note was never part of what a document claims. | | `remove` | `"free" \| "reason" \| "locked"` | yes | Deleting, and being absorbed by a merge. `locked` for **everybody** while a claim holds the entry — that is `on delete restrict` in the database, not a policy an owner can pass. | | `blockers` | `PolicyBlocker[]` | yes | Empty exactly when every field above is `free`. | ### EvaluateHints **Fields** | Field | Type | Required | Description | |---|---|---|---| | `hintIds` | `string (uuid)[]` | yes | This person’s own hints to evaluate — up to 50 per request. | ### ExportDocumentInput **Fields** | Field | Type | Required | Description | |---|---|---|---| | `documentId` | `string` | yes | The document's id in the invoicing tool the export went to. | ### ExportedInvoice **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `status` | `"draft" \| "transferred" \| "withdrawn"` | yes | 'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched. | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | | `periodStart` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `periodEnd` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `currency` | `string` | yes | ISO-4217 currency code. | | `subtotalMinor` | `integer` | yes | | | `notes` | `string \| null` | yes | | | `transferredAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `withdrawnAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `export` | `InvoiceExport` | yes | | | `transferredByHand` | `boolean` | yes | | | `document` | `InvoiceDocument` | yes | | | `recordedDocument` | `InvoiceRecordedDocument` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAtPrecise` | `string` | yes | | | `servicePeriodStart` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `servicePeriodEnd` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lines` | `InvoiceLine[]` | yes | | | `projects` | `object[]` | yes | | | `projects.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.name` | `string` | yes | | | `budgets` | `object[]` | yes | | | `budgets.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.name` | `string` | yes | | | `tasks` | `object[]` | yes | | | `tasks.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `tasks.name` | `string` | yes | | | `tasks.projectName` | `string` | yes | | | `exportNotice` | `string \| null` | yes | Set when sevdesk’s draft has a net total or a position count other than the invoice’s: the export is recorded, and this says what to check in sevdesk before finalizing. `null` otherwise, and always for Easybill. | ### ExternalClaimRepair **Fields** | Field | Type | Required | Description | |---|---|---|---| | `claims` | `integer` | yes | How many claims were written. | | `entries` | `integer` | yes | How many time entries they belong to. | | `seconds` | `integer` | yes | The seconds those claims cover — summed from what was written, not from the request. | ### ExternalClaimSelector **Fields** | Field | Type | Required | Description | |---|---|---|---| | `documentRef` | `string \| null` | yes | The document these claims carry today, verbatim — `null` for the group that names none. Required, so no request can select the whole history by omission. | | `projectId` | `string (uuid)` | no | Narrows to one project inside that document — the row under it. | | `customerId` | `string (uuid)` | no | Resource identifier (UUID v4). | ### ExternallyBilledCustomer **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `icon` | `object \| null` | yes | | | `icon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `icon.contentType` | `"image/webp" \| "image/png"` | yes | | | `icon.width` | `integer` | yes | | | `icon.height` | `integer` | yes | | | `icon.byteSize` | `integer` | yes | | | `icon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `icon.checksum` | `string` | yes | | | `archived` | `boolean` | yes | | | `projects` | `ExternallyBilledProjectRollup[]` | yes | | | `seconds` | `integer` | yes | Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`. | | `pricedSeconds` | `integer` | yes | The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency. | | `unratedSeconds` | `integer` | yes | Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed" is worth recording without a price. Never valued at zero. | | `otherCurrencySeconds` | `integer` | yes | Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one. | | `amountMinor` | `integer` | yes | Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent. | | `entryCount` | `integer` | yes | How many time entries contribute. An entry is counted once. | | `billedFrom` | `string (date-time)` | yes | Start of the earliest billed second. | | `billedUntil` | `string (date-time)` | yes | End of the latest billed second. | ### ExternallyBilledDocument **Fields** | Field | Type | Required | Description | |---|---|---|---| | `documentRef` | `string \| null` | yes | The other system’s document, verbatim: `harvest:invoice::`. Split on the first three colons — everything after them is the number, the only segment that may contain one. `null` is not missing data: the source called these hours billed while naming no document, which is how hours nobody will ever be paid for were got out of Harvest’s uninvoiced report. That group is the one to act on, and `total.withoutDocument` states it on its own. | | `claimCount` | `integer` | yes | How many claims the document holds. More than one per entry is the ordinary extended-entry case, not a duplicate. | | `rates` | `ExternallyBilledRate[]` | yes | The distinct rates these hours were billed at, largest share first — so a client says one rate or „gemischt" rather than an average nothing was billed at (invariant 18). | | `people` | `ExternallyBilledPerson[]` | yes | Whose hours are on the document, in seconds. Deliberately no amount per person: that is a different question, and invariant 19 wants a monetary total to cover one person by intent. | | `projects` | `ExternallyBilledProject[]` | yes | The projects the document billed, most hours first. | | `seconds` | `integer` | yes | Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`. | | `pricedSeconds` | `integer` | yes | The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency. | | `unratedSeconds` | `integer` | yes | Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed" is worth recording without a price. Never valued at zero. | | `otherCurrencySeconds` | `integer` | yes | Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one. | | `amountMinor` | `integer` | yes | Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent. | | `entryCount` | `integer` | yes | How many time entries contribute. An entry is counted once. | | `billedFrom` | `string (date-time)` | yes | Start of the earliest billed second. | | `billedUntil` | `string (date-time)` | yes | End of the latest billed second. | ### ExternallyBilledPerson **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `personName` | `string` | yes | | | `seconds` | `integer` | yes | | ### ExternallyBilledProject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectName` | `string` | yes | | | `archived` | `boolean` | yes | An archived project stays listed: archiving never hides historical money or names (invariant 54), and an abandoned project is the ordinary case here. | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `customerArchived` | `boolean` | yes | | | `seconds` | `integer` | yes | Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`. | | `pricedSeconds` | `integer` | yes | The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency. | | `unratedSeconds` | `integer` | yes | Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed" is worth recording without a price. Never valued at zero. | | `otherCurrencySeconds` | `integer` | yes | Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one. | | `amountMinor` | `integer` | yes | Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent. | | `entryCount` | `integer` | yes | How many time entries contribute. An entry is counted once. | | `billedFrom` | `string (date-time)` | yes | Start of the earliest billed second. | | `billedUntil` | `string (date-time)` | yes | End of the latest billed second. | | `customerIcon` | `object \| null` | yes | | | `customerIcon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `customerIcon.contentType` | `"image/webp" \| "image/png"` | yes | | | `customerIcon.width` | `integer` | yes | | | `customerIcon.height` | `integer` | yes | | | `customerIcon.byteSize` | `integer` | yes | | | `customerIcon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon.checksum` | `string` | yes | | ### ExternallyBilledProjectRollup **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectName` | `string` | yes | | | `archived` | `boolean` | yes | An archived project stays listed: archiving never hides historical money or names (invariant 54), and an abandoned project is the ordinary case here. | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `customerArchived` | `boolean` | yes | | | `seconds` | `integer` | yes | Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`. | | `pricedSeconds` | `integer` | yes | The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency. | | `unratedSeconds` | `integer` | yes | Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed" is worth recording without a price. Never valued at zero. | | `otherCurrencySeconds` | `integer` | yes | Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one. | | `amountMinor` | `integer` | yes | Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent. | | `entryCount` | `integer` | yes | How many time entries contribute. An entry is counted once. | | `billedFrom` | `string (date-time)` | yes | Start of the earliest billed second. | | `billedUntil` | `string (date-time)` | yes | End of the latest billed second. | | `documentRefs` | `(string \| null)[]` | yes | The documents this project’s hours sit on, one entry per document, `null` included — the answer to „auf welchen Rechnungen steht dieses Projekt". | ### ExternallyBilledRate **Fields** | Field | Type | Required | Description | |---|---|---|---| | `rateMinor` | `integer \| null` | yes | What the other system billed per hour, or `null` where it stated no rate. | | `currency` | `string` | yes | ISO-4217 currency code. | | `seconds` | `integer` | yes | | ### ExternallyBilledSummary **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `total` | `ExternallyBilledTotal` | yes | | | `documents` | `ExternallyBilledDocument[]` | yes | The „no document" group first — it is the one to act on — then the numbered documents by invoice number descending, and last a document whose number the source left empty, which there is no number to order by. Numbers compare numerically, so 1042 sorts above 999. | | `customers` | `ExternallyBilledCustomer[]` | yes | The same seconds by customer and project, most hours first. | ### ExternallyBilledTotal **Fields** | Field | Type | Required | Description | |---|---|---|---| | `seconds` | `integer` | yes | Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`. | | `pricedSeconds` | `integer` | yes | The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency. | | `unratedSeconds` | `integer` | yes | Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed" is worth recording without a price. Never valued at zero. | | `otherCurrencySeconds` | `integer` | yes | Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one. | | `amountMinor` | `integer` | yes | Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent. | | `entryCount` | `integer` | yes | How many time entries contribute. An entry is counted once. | | `documentCount` | `integer` | yes | Distinct documents, counting the „no document" group as one where it has hours. | | `withoutDocument` | `object` | yes | The `documentRef: null` group on its own — hours the source called billed while naming no document. Zero everywhere when there are none. | | `withoutDocument.seconds` | `integer` | yes | Every second a live external claim settles at this level — `pricedSeconds + unratedSeconds + otherCurrencySeconds`. | | `withoutDocument.pricedSeconds` | `integer` | yes | The seconds `amountMinor` is built from: the other system stated a rate, in the organization’s current currency. | | `withoutDocument.unratedSeconds` | `integer` | yes | Billed with no rate stated at all. Not an error — `billed_rate_minor` is nullable for `external` claims alone, because „Harvest says these hours were billed" is worth recording without a price. Never valued at zero. | | `withoutDocument.otherCurrencySeconds` | `integer` | yes | Billed in a currency the organization no longer uses. Never converted and never part of `amountMinor`; `GET /invoices/divergences` reports the same claims one by one. | | `withoutDocument.amountMinor` | `integer` | yes | Reconstructed from rate times seconds, rounded once per (document, project, rate) group and summed upwards — **not** the other system’s invoice total, which Timjami never receives. Pauschalen, Auslagen, discounts and tax are not hours and are absent. | | `withoutDocument.entryCount` | `integer` | yes | How many time entries contribute. An entry is counted once. | ### ExternalReference **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | yes | The ticket system. `generic` is any system that is not one of the four named ones — the server never checks the provider against the URL’s host, because a self-hosted Jira or Azure DevOps Server lands on a host nothing here can enumerate. | | `key` | `string` | yes | The ticket’s identifier in that system, as that system writes it. | | `url` | `string \| null` | yes | Where the ticket lives. Always `https://`, and `null` where nobody supplied one. | | `title` | `string \| null` | yes | The ticket’s title as it read when this was attached — a label, never a cache. It is allowed to go stale. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | ### Feature Type: `"harvest" | "company-metrics" | "agent-usage" | "day-export" | "tour" | "jami" | "jami-notes" | "max-view" | "entry-split" | "planning" | "customer-portal" | "tickets" | "absences"` ### FixedPriceAgreement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `tasks` | `object[]` | yes | The tasks whose hours are spent on this agreement: `fixed_price` ones carry the sold work, `internal` ones the investment around it. | | `tasks.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `tasks.name` | `string` | yes | | | `tasks.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `tasks.billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | yes | How this task’s hours are billed. `hourly` is priced by the hour; `productive` is value-creating work that is never invoiced; `internal` is neither; `fixed_price` is billed through the task’s fixed-price agreement, not by the hour. Prefer this over `isBillable` and `isProductive`, which are derived from it. | | `tasks.archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `items` | `FixedPriceItem[]` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### FixedPriceControlling **Fields** | Field | Type | Required | Description | |---|---|---|---| | `agreementId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `currency` | `string` | yes | The organization’s currency; every amount here is in it. | | `from` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `until` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `hours` | `object` | yes | Finished hours of everybody who worked on the agreement, split by kind. | | `hours.fixedPriceSeconds` | `integer` | yes | Hours on the agreement’s `fixed_price` tasks — the sold work. | | `hours.investmentSeconds` | `integer` | yes | Hours on the agreement’s `productive` and `internal` tasks — our own investment around it. | | `hours.totalSeconds` | `integer` | yes | | | `people` | `object[]` | yes | The same split per person, most hours first. | | `people.fixedPriceSeconds` | `integer` | yes | Hours on the agreement’s `fixed_price` tasks — the sold work. | | `people.investmentSeconds` | `integer` | yes | Hours on the agreement’s `productive` and `internal` tasks — our own investment around it. | | `people.totalSeconds` | `integer` | yes | | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `revenue` | `object` | yes | | | `revenue.amountMinor` | `integer` | yes | The `service` items’ amounts in the organization’s currency, spread over their service days (and so cut to the window by days) — taken from the items, never from the hours. | | `revenue.itemCount` | `integer` | yes | Items counted in the amount. | | `revenue.otherCurrencyItemCount` | `integer` | yes | Items in another currency: counted here, never summed into the amount. | | `rates` | `object` | yes | Two effective rates side by side, each stating its divisor. `perAllHours` is revenue ÷ **all** hours — what the fixed price earned per hour, investment included. `perFixedPriceHours` is revenue ÷ **fixed-price** hours — how well it was sold, without our own investment. | | `rates.perAllHours` | `FixedPriceControllingRate` | yes | | | `rates.perFixedPriceHours` | `FixedPriceControllingRate` | yes | | | `rounding` | `"half-up-minor-unit-per-hour"` | yes | How the rates are rounded: once, half up, to a whole minor unit per hour. Seconds and amounts are never rounded. | | `scope` | `"organization"` | yes | Whose work the figures cover: everybody who booked on the agreement, not one person. | ### FixedPriceControllingRate **Fields** | Field | Type | Required | Description | |---|---|---|---| | `amountMinor` | `integer` | yes | The revenue the rate divides, in minor units of `currency`. | | `seconds` | `integer` | yes | The hours the rate divides by, in seconds — exactly the ones it names. | | `rateMinorPerHour` | `integer \| null` | yes | `amountMinor` per hour, rounded **once** to a whole minor unit, half up. `null` without hours — no hours is not a rate of zero. | ### FixedPriceItem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `agreementId` | `string (uuid) \| null` | yes | The agreement the item belongs to. `null` only for a standalone `expense`. | | `projectId` | `string (uuid)` | yes | The project the item is billed under. Its customer is the agreement’s. | | `kind` | `"service" \| "expense"` | yes | `service` is revenue that people share by weight (a workshop’s day fee) and always belongs to an agreement. `expense` is passed through and counts as no revenue (travel costs); it may stand alone on a project. | | `description` | `string` | yes | Becomes the invoice line’s description, together with the service days. | | `quantityHundredths` | `integer` | yes | The quantity in hundredths of `unit`: 200 is two days. Independent of how many days the item has. | | `unit` | `string` | yes | | | `unitPriceMinor` | `integer` | yes | The price per unit in minor units, entered per item. | | `amountMinor` | `integer` | yes | `round(quantity × unit price)` in minor units, rounded once when the item was written and stored. | | `currency` | `string` | yes | The organization’s currency when the item was written. | | `easybillPositionId` | `string \| null` | yes | The Easybill article this item’s invoice line references, overriding the kind’s own (it carries the VAT rate and the ledger account). `null` means the kind’s article. | | `days` | `string[]` | yes | The service days — a set of calendar days in the organization’s time zone, ascending. Friday and Monday are two days, not four. | | `people` | `object[]` | yes | Who shares a `service` item’s revenue. Empty for an `expense`. | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `people.weight` | `integer` | yes | The share of the item’s revenue, relative to the other people. Equal by default. | | `policy` | `FixedPriceItemPolicy` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### FixedPriceItemBlocker Type: `object | object` ### FixedPriceItemPolicy **Fields** | Field | Type | Required | Description | |---|---|---|---| | `state` | `"free" \| "closed" \| "billed"` | yes | `billed` — a live invoice claim holds the item. `closed` — a day of the item lies behind a period close or the tracking deadline. `free` — neither. A live claim wins over a closed day. | | `update` | `"free" \| "reason" \| "locked"` | yes | `locked` for **everybody** while `billed` — the invoice line states the item’s quantity, price and days. `reason` for an owner or admin on a `closed` item. | | `remove` | `"free" \| "reason" \| "locked"` | yes | Like `update`, and `locked` for everybody while **any** invoice claim names the item, a withdrawn one included — delete that invoice first. | | `blockers` | `FixedPriceItemBlocker[]` | yes | Why the state is `closed`; empty otherwise. | ### FixedPriceRevision **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `subject` | `"agreement" \| "item"` | yes | | | `subjectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `agreementId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `projectId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `action` | `"created" \| "updated" \| "deleted"` | yes | | | `actorPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `recordedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `snapshot` | `object` | yes | The subject as it stood **before** this write; for `created`, its initial values. | | `reason` | `string \| null` | yes | | ### GoogleCalendarAuthorizeUrl **Fields** | Field | Type | Required | Description | |---|---|---|---| | `url` | `string (uri)` | yes | | ### GoogleCalendarConnection **Fields** | Field | Type | Required | Description | |---|---|---|---| | `configured` | `boolean` | yes | | | `connection` | `object \| null` | yes | | | `connection.googleAccountEmail` | `string (email)` | yes | | | `connection.calendarIds` | `string[]` | yes | | | `connection.includeDetails` | `boolean` | yes | | | `connection.connectedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `connection.lastSyncedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `connection.authorizationFailedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | ### GoogleCalendarList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.name` | `string` | yes | | | `data.primary` | `boolean` | yes | | | `data.selected` | `boolean` | yes | | ### GoogleCalendarSync **Fields** | Field | Type | Required | Description | |---|---|---|---| | `reported` | `integer` | yes | | | `status` | `"reported" \| "not-configured" \| "not-connected" \| "no-calendars" \| "authorization-failed" \| "outside-retention" \| "future-window" \| "throttled" \| "failed"` | yes | Why nothing was reported, when nothing was. `failed` is a bad minute at Google and heals by itself; `authorization-failed` never does and needs reconnecting. | ### GoogleProvisionResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `outcome` | `"joined" \| "already-member" \| "no-google-account" \| "no-hosted-domain" \| "no-matching-domain" \| "domain-not-currently-verified" \| "membership-conflict"` | yes | | | `organizationId` | `string \| null` | yes | | ### HarvestAdoptRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryIds` | `string (uuid)[]` | yes | The entries to correct. The export’s cap rather than the comparison’s: this spends one Harvest request per entry and follows each with a local transaction. | | `personId` | `string (uuid)` | no | Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | ### HarvestAdoptResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `results` | `object[]` | yes | | | `results.timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `results.status` | `"adopted" \| "unchanged" \| "failed"` | yes | | | `results.harvestTimeEntryId` | `string` | no | | | `results.adopted` | `("date" \| "start" \| "end" \| "notes" \| "task")[]` | no | | | `results.reason` | `"foreign-user" \| "running" \| "not-synchronised" \| "foreign-account-link" \| "harvest-deleted" \| "missing-project-link" \| "missing-task-link" \| "inconsistent-task-link" \| "ambiguous-task-link" \| "no-clock-times" \| "ends-before-start" \| "archived-chain" \| "not-assigned" \| "billed" \| "rejected" \| "aborted"` | no | | | `results.detail` | `string` | no | | | `adopted` | `integer` | yes | | | `unchanged` | `integer` | yes | | | `failed` | `integer` | yes | | ### HarvestCompareFieldDifferenceDetail **Fields** | Field | Type | Required | Description | |---|---|---|---| | `field` | `"date" \| "start" \| "end" \| "notes" \| "project" \| "task"` | yes | | | `expected` | `string` | yes | | | `remote` | `string` | yes | | ### HarvestCompareRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryIds` | `string (uuid)[]` | yes | The entries to compare, at most 2000. Far more than an export batch accepts, because a comparison reads Harvest’s copies as one range rather than one request per entry — see ADR-2026-08-18-14GLB. Equal to what one import takes over, so a whole reconciled period fits in both directions (ADR-2026-08-27-15EG4). | | `personId` | `string (uuid)` | no | Whose Harvest period this is about. Omit for the caller’s own. Naming somebody else requires `time.readOthers`, and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | ### HarvestCompareResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `results` | `object[]` | yes | | | `results.timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `results.status` | `"matches" \| "diverged" \| "harvest-deleted" \| "failed"` | yes | | | `results.harvestTimeEntryId` | `string` | no | | | `results.differences` | `("date" \| "start" \| "end" \| "notes" \| "project" \| "task")[]` | no | | | `results.differenceDetails` | `HarvestCompareFieldDifferenceDetail[]` | no | | | `results.detail` | `string` | no | | | `results.localUpdatedAt` | `string (date-time)` | no | ISO-8601 timestamp with offset. | | `results.remoteUpdatedAt` | `string (date-time)` | no | ISO-8601 timestamp with offset. | ### HarvestConnection **Fields** | Field | Type | Required | Description | |---|---|---|---| | `configured` | `boolean` | yes | | | `connection` | `object \| null` | yes | | | `connection.accountId` | `string` | yes | | | `connection.harvestUserEmail` | `string (email)` | yes | | | `connection.harvestUserName` | `string` | yes | | | `connection.harvestUserTimezone` | `string` | yes | | | `connection.timestampMode` | `boolean` | yes | | | `connection.connectedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `connection.lastVerifiedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `connection.authorizationFailedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | ### HarvestExportRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryIds` | `string (uuid)[]` | yes | | ### HarvestExportResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `results` | `object[]` | yes | | | `results.timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `results.status` | `"created" \| "updated" \| "failed"` | yes | | | `results.harvestTimeEntryId` | `string` | no | | | `results.reason` | `"foreign-user" \| "running" \| "spans-midnight" \| "shorter-than-a-minute" \| "missing-project-link" \| "missing-task-link" \| "inconsistent-task-link" \| "foreign-account-link" \| "in-flight" \| "harvest-deleted" \| "harvest-locked" \| "ambiguous" \| "rejected" \| "aborted"` | no | | | `results.detail` | `string` | no | | | `created` | `integer` | yes | | | `updated` | `integer` | yes | | | `failed` | `integer` | yes | | ### HarvestImportRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | Inclusive. Handed to Harvest as its own `spent_date` bound unchanged, so the range is meant in Harvest's time zone — not the organization's. | | `to` | `string` | yes | Inclusive, and never before `from`. | | `preview` | `boolean` | yes | When `true`, nothing is written and every result says what would happen. | | `harvestTimeEntryIds` | `string[]` | no | Harvest time entry ids to take over, out of the range. Omit for the whole range. Given, the answer holds exactly one result per requested id, in this order — an id the range does not hold answers `not-in-range`. The range is still read whole, because an entry's reconstructed placement depends on the previous entry of its Harvest day. At most 2000. | | `personId` | `string (uuid)` | no | Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | ### HarvestImportResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `preview` | `boolean` | yes | | | `results` | `object[]` | yes | | | `results.harvestTimeEntryId` | `string` | yes | | | `results.status` | `"imported" \| "skipped"` | yes | | | `results.timeEntryId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `results.reason` | `"not-in-range" \| "already-linked" \| "thymian-deleted" \| "thymian-origin" \| "missing-project-link" \| "missing-task-link" \| "inconsistent-task-link" \| "ambiguous-task-link" \| "foreign-account-link" \| "no-clock-times" \| "harvest-running" \| "ends-before-start" \| "double-billed" \| "target-unassigned"` | no | | | `results.detail` | `string` | no | | | `results.archived` | `"task" \| "project" \| "customer"` | no | The innermost archived level of the Timjami chain this entry lands on — absent for a live one. An archived chain is booked rather than refused here, unlike every other way of creating time: the hours were tracked elsewhere, on a task somebody chose while the chain was live, so the row states what it landed on instead of stopping. Present on an `imported` row in a preview exactly as in a run, and named like the `archived` member of the 409 the other booking paths still answer. See ADR-2026-08-24-11766. | | `results.plannedSeconds` | `integer \| null` | yes | The seconds this run books for the entry, whatever its status. Harvest publishes a second-precise duration only as decimal `hours` and its boundaries only as `HH:mm`, so the import places a pair inside those two minutes that is `hours` long — this is that length. It falls back to the clock span when Harvest states no hours, or when hours and clock times disagree by a minute or more so that no such pair exists, and is `null` when Harvest states no clock times at all. Summing it is what a period adds up to here after an import; the difference against `hours` is the residue no placement can hold. See ADR-2026-08-20-07UAD. | | `results.plannedStartedAt` | `string (date-time) \| null` | yes | The instant `plannedSeconds` is placed to start at. `null` alongside `plannedSeconds`. The offset inside the published minute carries the rule that an entry starts no earlier than the previous one of that Harvest day ended, which is a property of the whole day — so an entry whose own length already matches can still be one the placement moves. See ADR-2026-08-20-07ME9. | | `results.plannedEndedAt` | `string (date-time) \| null` | yes | The instant `plannedSeconds` is placed to end at. `null` alongside it. | | `results.harvest` | `object` | yes | | | `imported` | `integer` | yes | | | `skipped` | `integer` | yes | | | `claimsWritten` | `integer` | yes | How many of these entries this run marked as already billed in Harvest — an `external` claim over the whole entry, so the hours leave „Abzurechnen" and can never be billed a second time here. Counted for freshly imported entries and for already-linked ones this run caught up with, which is why it is its own number rather than part of `imported`: an entry that was already here stays `skipped` and the claim is what changed about it. In a preview it counts what *would* be written. See ADR-2026-08-24-11D67. | | `truncated` | `boolean` | yes | `true` when the range holds more than 2000 entries; the rest were not examined. | ### HarvestPeriodSummary **Fields** | Field | Type | Required | Description | |---|---|---|---| | `totalSeconds` | `integer` | yes | Harvest's `total_hours`, as seconds. Harvest's own quantum is 0.01 h, so this figure is 36-second-grained — it is Harvest's answer, not a recomputation of it. | | `billableSeconds` | `integer` | yes | Harvest's `billable_hours`, as seconds. What *Harvest* holds for sale, which is its own task-assignment setting and not Timjami's `task.is_billable`. | | `amounts` | `object[]` | yes | One entry per currency, because Harvest reports one row per person **and** currency. Never added together: a sum over two currencies is not a number. Empty when Harvest named no billable amount at all. | | `amounts.currency` | `string` | yes | | | `amounts.billableAmountMinor` | `integer` | yes | | | `rowsWithoutAmount` | `integer` | yes | Report rows that state billable hours but no amount — counted and named rather than summed as zero (invariant 19). | ### HarvestProjectLink **Fields** | Field | Type | Required | Description | |---|---|---|---| | `harvestProjectId` | `string` | yes | | | `linkedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### HarvestProjectList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.name` | `string` | yes | | ### HarvestProjectMapping **Fields** | Field | Type | Required | Description | |---|---|---|---| | `harvestProjectId` | `string` | yes | | ### HarvestProjectTeamImportRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | Inclusive. Handed to Harvest as its own `spent_date` bound unchanged, so the range is meant in Harvest's time zone — not the organization's. | | `to` | `string` | yes | Inclusive, and never before `from`. | | `preview` | `boolean` | yes | When `true`, nothing is written and every result says what would happen. | ### HarvestProjectTeamImportResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `preview` | `boolean` | yes | | | `members` | `object[]` | yes | | | `members.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `members.harvestMatch` | `"matched" \| "unmatched" \| "ambiguous" \| "denied"` | yes | `matched` ran an ordinary import for this person; every other status wrote nothing. `unmatched`/`ambiguous` mean no or no unambiguous Harvest account shares this person’s email. `denied` means Harvest refused this one person’s hours to the importer’s own token — reported rather than ending the run for everybody else. | | `members.imported` | `integer` | yes | | | `members.skipped` | `integer` | yes | | | `members.claimsWritten` | `integer` | yes | | | `members.truncated` | `boolean` | yes | | | `members.results` | `object[]` | yes | | | `imported` | `integer` | yes | | | `skipped` | `integer` | yes | | | `claimsWritten` | `integer` | yes | | | `truncated` | `boolean` | yes | `true` when any one member's own range was truncated. | | `otherTrackers` | `object[]` | yes | Everybody who tracked hours on this Harvest project in the range and is not among `members` — see the field descriptions above. See ADR-2026-08-27-13NS7. | | `otherTrackers.harvestUserId` | `string` | yes | | | `otherTrackers.harvestUserName` | `string \| null` | yes | | | `otherTrackers.harvestUserEmail` | `string \| null` | yes | `null` when no `GET /users` row matches this id — a deactivated Harvest account, or Harvest never listed it. | | `otherTrackers.status` | `"unassigned-member" \| "ambiguous" \| "no-thymian-account"` | yes | `unassigned-member`: the email matches exactly one organization member who is not a project member — assign them to pick these hours up on the next run. `ambiguous`: the email matches more than one Harvest or Timjami account. `no-thymian-account`: no organization member shares this email at all. | | `otherTrackers.personId` | `string (uuid)` | no | Present only for `unassigned-member` — the Timjami person to assign. | | `otherTrackersTruncated` | `boolean` | yes | `true` when the project-wide read behind `otherTrackers` held more than the cap. | ### HarvestSecondsRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `to` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `preview` | `boolean` | no | | | `personId` | `string (uuid)` | no | Whose hours these are. Omit for the caller’s own. Naming somebody else requires `time.importOthers` (a preview needs only `time.readOthers`), and the caller’s own connected Harvest token is what reads either way — the named person needs no Harvest connection. Their Harvest account is resolved by email address, fresh on every request. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | ### HarvestSecondsResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `preview` | `boolean` | yes | | | `results` | `object[]` | yes | | | `results.timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `results.harvestTimeEntryId` | `string` | yes | | | `results.status` | `"corrected" \| "unchanged" \| "failed"` | yes | | | `results.reason` | `"no-placement" \| "foreign-account-link" \| "foreign-user" \| "running" \| "not-minute-precise" \| "thymian-origin" \| "billed"` | no | | | `results.detail` | `string` | no | | | `results.fromSeconds` | `integer` | no | | | `results.toSeconds` | `integer` | no | | | `corrected` | `integer` | yes | | | `unchanged` | `integer` | yes | | | `failed` | `integer` | yes | | | `gainedSeconds` | `integer` | yes | Seconds the corrections added to the period's total — what the difference against Harvest's own report shrinks by. Negative where Harvest's hours are below its published minutes. | | `openSeams` | `integer` | yes | Places where one entry was placed to end after the next one could possibly start. Not a defect: an entry's length is Harvest's own and its boundaries may not leave the minutes Harvest published, and those two outrank „no overlap". Where Harvest's hours for a stretch add up to more than its own minutes hold, some overlap is forced — reported here rather than hidden behind a shortened duration. Always under two minutes per seam. | | `openSeamSeconds` | `integer` | yes | How much those seams overlap in total, seconds. | | `harvestOverlaps` | `integer` | yes | Of `openSeams`, the ones where **Harvest's own published windows** already overlap — two Harvest entries whose `HH:mm` ranges cross. No placement of any kind could have avoided those, and a shortened duration would not close them either. | | `truncated` | `boolean` | yes | `true` when Harvest holds more than 2000 entries in the range; the rest were not examined. | ### HarvestTaskList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.name` | `string` | yes | | ### HarvestTaskMapping **Fields** | Field | Type | Required | Description | |---|---|---|---| | `harvestProjectId` | `string` | yes | | | `harvestTaskId` | `string` | yes | | ### Hint **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `userId` | `string` | yes | | | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `signal` | `"presence" \| "absence"` | yes | | | `externalId` | `string \| null` | yes | | | `clientHintId` | `string \| null` | yes | | | `path` | `HintPath` | yes | | | `details` | `string \| null` | yes | | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `durationSeconds` | `integer \| null` | yes | | | `occurrenceCount` | `integer` | yes | | | `version` | `integer` | yes | | | `timeEntryId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `dismissedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `isResolved` | `boolean` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### HintClassification **Fields** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"presence" \| "task" \| "ignore" \| "customer" \| "project" \| "absence"` | yes | `presence` = evidence that you worked, not what on. `task` = names one task. `ignore` = noise, hidden from the panel. `customer`/`project` = narrows the task picker to that customer or project without naming a task. `absence` = evidence that you did *not* work — the declared opposite of `presence`. | | `taskId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `projectId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | ### HintEvaluation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `suggestions` | `object[]` | yes | | | `suggestions.hintIds` | `string (uuid)[]` | yes | Which of the requested hints this suggestion covers. | | `suggestions.taskId` | `string (uuid) \| null` | yes | The suggested task, or `null` where nothing matched confidently. | | `suggestions.summary` | `string` | yes | A short, human-readable description of the covered hints. | | `suggestions.confidence` | `"low" \| "medium" \| "high"` | yes | The model’s own self-assessment — not validated against anything. | ### HintPath Type: `HintPathNode[]` ### HintPathNode **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | Stable id from the source system — never renamed, unlike the label. | | `label` | `string` | yes | | ### HintRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `path` | `HintPath` | yes | | | `kind` | `"presence" \| "task" \| "ignore" \| "customer" \| "project" \| "absence"` | yes | `presence` = evidence that you worked, not what on. `task` = names one task. `ignore` = noise, hidden from the panel. `customer`/`project` = narrows the task picker to that customer or project without naming a task. `absence` = evidence that you did *not* work — the declared opposite of `presence`. | | `taskId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `projectId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### HintRuleInput **Fields** | Field | Type | Required | Description | |---|---|---|---| | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `subject` | `string` | yes | | | `path` | `HintPath` | no | | | `kind` | `"presence" \| "task" \| "ignore" \| "customer" \| "project" \| "absence"` | yes | `presence` = evidence that you worked, not what on. `task` = names one task. `ignore` = noise, hidden from the panel. `customer`/`project` = narrows the task picker to that customer or project without naming a task. `absence` = evidence that you did *not* work — the declared opposite of `presence`. | | `taskId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | ### HintSubject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `subject` | `string` | yes | | | `path` | `HintPath` | yes | | | `hintCount` | `integer` | yes | | | `occurrenceCount` | `integer` | yes | | | `observedSeconds` | `integer` | yes | | | `firstSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### HintTaskSuggestion **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `origin` | `"rule" \| "history"` | yes | | | `lastBookedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `acceptedCount` | `integer \| null` | yes | | ### HintWithSuggestion **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `userId` | `string` | yes | | | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `signal` | `"presence" \| "absence"` | yes | | | `externalId` | `string \| null` | yes | | | `clientHintId` | `string \| null` | yes | | | `path` | `HintPath` | yes | | | `details` | `string \| null` | yes | | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `durationSeconds` | `integer \| null` | yes | | | `occurrenceCount` | `integer` | yes | | | `version` | `integer` | yes | | | `timeEntryId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `dismissedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `isResolved` | `boolean` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `taskSuggestion` | `HintTaskSuggestion` | yes | | | `classifications` | `HintClassification[]` | yes | | ### HistoricalPeriodPreview **Fields** | Field | Type | Required | Description | |---|---|---|---| | `periodStartMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `outstandingSeconds` | `integer` | yes | Open seconds a history mode would take on, allowance months included. | | `fixedPriceItemCount` | `integer` | yes | Completed fixed-price items it would take on. They carry no seconds, so a period can hold these alone. | ### HoursOverTime **Fields** | Field | Type | Required | Description | |---|---|---|---| | `bucket` | `"week" \| "month" \| "quarter"` | yes | The grain, chosen from the range: weeks up to 92 days, months up to 24 months, quarters beyond. | | `series` | `HoursOverTimeSeries[]` | yes | Ordered by all-time seconds on this subject, whatever the window — so a colour stays with its series as the period changes. Settlement is always its four statuses in a fixed order; `self` comes before `others`. | | `buckets` | `HoursOverTimeBucket[]` | yes | Every bucket of the range, an empty one as zero. Empty when `from` is omitted and the subject has no hours. | ### HoursOverTimeBucket **Fields** | Field | Type | Required | Description | |---|---|---|---| | `firstDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `lastDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `totalSeconds` | `integer` | yes | | | `seconds` | `object` | yes | Seconds per series key; a key without seconds in this bucket is absent. Every key is one of `series`. | ### HoursOverTimeSeries **Fields** | Field | Type | Required | Description | |---|---|---|---| | `key` | `string` | yes | A person id, a project id, a settlement status (`billed`, `open`, `written-off`, `not-hourly`), or `self` and `others` for a viewer without `report.readOthers`. | | `label` | `string \| null` | yes | The person’s or project’s name; `null` for settlement statuses and for `self`/`others`, which the client words. | | `color` | `string \| null` | yes | The project’s own colour; `null` everywhere else. | ### ImpersonationSession **Fields** | Field | Type | Required | Description | |---|---|---|---| | `sessionToken` | `string` | yes | | | `sessionId` | `string` | yes | | | `expiresAt` | `string (date-time)` | yes | | | `user` | `object` | yes | | | `user.id` | `string` | yes | | | `user.name` | `string` | yes | | | `user.email` | `string (email)` | yes | | | `user.image` | `string \| null` | yes | | ### InsertBreak **Fields** | Field | Type | Required | Description | |---|---|---|---| | `startedAt` | `string (date-time)` | yes | When the break started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time)` | yes | When it ended. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `affected` | `string (uuid)[]` | yes | Every own entry overlapping `[startedAt, endedAt)`; each is cut. Must name exactly the overlapping set — any other set answers **409**, and the caller reads the day again. | | `editReason` | `string` | no | Why entries are being cut or deleted inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends. | ### InsertTimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | yes | When the work started. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time)` | yes | When it ended — required: the window is what gets cut out. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `notes` | `string \| null` | no | | | `affected` | `object[]` | yes | Every own entry overlapping `[startedAt, endedAt)`, each with the caller’s choice: `keep: true` leaves it overlapping, `keep: false` cuts it. Must name exactly the overlapping set — any other set answers **409**, and the caller reads the day again. | | `affected.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `affected.keep` | `boolean` | yes | | | `editReason` | `string` | no | Why hours are being booked, cut or deleted inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends. | ### InviteCodeCheckRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `code` | `string` | yes | An invite code, in any casing or spacing — normalized server-side. | ### InviteCodeCheckResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `email` | `string \| null` | yes | The address this code is bound to, or null when it redeems for anybody. | ### InviteCodeRedemption **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | | `userId` | `string \| null` | yes | Null once the account was deleted; the redemption still counts as a use of the code. | | `userEmail` | `string (email) \| null` | yes | | | `userName` | `string \| null` | yes | | | `redeemedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `organizationId` | `string \| null` | yes | | | `organizationName` | `string \| null` | yes | | ### Invoice **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `status` | `"draft" \| "transferred" \| "withdrawn"` | yes | 'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched. | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | | `periodStart` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `periodEnd` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `currency` | `string` | yes | ISO-4217 currency code. | | `subtotalMinor` | `integer` | yes | | | `notes` | `string \| null` | yes | | | `transferredAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `withdrawnAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `export` | `InvoiceExport` | yes | | | `transferredByHand` | `boolean` | yes | | | `document` | `InvoiceDocument` | yes | | | `recordedDocument` | `InvoiceRecordedDocument` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAtPrecise` | `string` | yes | | ### InvoiceDocument **Fields** | Field | Type | Required | Description | |---|---|---|---| | `provider` | `InvoicingProvider & unknown` | yes | | | `state` | `"deleted" \| "cancelled" \| "draft" \| "paid" \| "partiallyPaid" \| "overdue" \| "sent" \| "finalized" \| "open" \| "unknown"` | yes | The state of the document in the invoicing tool, read off its fields by Timjami — neither tool has a status that says all of this. The first that holds wins: `deleted` the tool answers 404 for it; `cancelled` a Storno document names it; `draft` not finalised yet; `paid` the paid amount covers it; `partiallyPaid` something but not all of it is paid; `overdue` its due day has passed; `sent` a send was triggered from Easybill; `finalized` Easybill, none of the above; `open` sevdesk, finalised and neither paid nor overdue; `unknown` a sevdesk status Timjami does not interpret. Easybill never answers `open` or `unknown`, sevdesk never `sent` or `finalized`. | | `number` | `string \| null` | yes | The Rechnungsnummer the tool assigned on finalising — `null` while a draft. | | `archived` | `boolean \| null` | yes | Easybill only, `null` otherwise: archived there. Not a state: an archived invoice is still paid or not. | | `amountMinor` | `integer \| null` | yes | The tool's gross total in minor units of the invoice's `currency` — the tool's figure, including its VAT, not Timjami's. This and the two amounts below are `null` when the document was switched to another currency there. | | `amountNetMinor` | `integer \| null` | yes | Easybill only, `null` otherwise: its net total in minor units. Easybill computes it from the exported quantities, rounded to two places of an hour (ADR-2026-09-07-12QAJ) — the same arithmetic `subtotalMinor` uses since ADR-2026-10-08-14AU2, so the two differ only where somebody edited the document, or for an invoice frozen before that change. | | `paidAmountMinor` | `integer \| null` | yes | What the tool records as paid, in minor units. | | `paidDay` | `string \| null` | yes | When the tool records it as paid. | | `documentDay` | `string \| null` | yes | The Rechnungsdatum. | | `dueDay` | `string \| null` | yes | The Fälligkeitsdatum. sevdesk has no such field: there it is the Rechnungsdatum plus the Zahlungsziel in calendar days, and `null` for an invoice without a Zahlungsziel — Timjami does not guess one. | | `duePassed` | `boolean` | yes | Whether `dueDay` is behind the organization's today. Its own field rather than `state === 'overdue'`, because that state is a label chosen by precedence and `partiallyPaid` outranks it — a document half paid and months late carries the paid label and is late all the same. `GET /invoices/easybill-outstanding` splits its overdue figure by this same reading. | | `editedDay` | `string \| null` | yes | Easybill only, `null` otherwise: the day it last changed the document. A day and not a timestamp, because Easybill states its time without a zone. | | `sentAt` | `string (date-time) \| null` | yes | sevdesk only, `null` otherwise: when the document was sent, as sevdesk records it. | | `fetchedAt` | `string (date-time)` | yes | When Timjami last read this from the tool — how current everything above is. | ### InvoiceDraft **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `lines` | `InvoiceDraftLine[]` | yes | | | `subtotalMinor` | `integer` | yes | | | `entryCount` | `integer` | yes | | | `durationSeconds` | `integer` | yes | | | `allowanceSeconds` | `integer` | yes | The part of `durationSeconds` that is monthly time allowances („Pauschale") rather than tracked time — the document’s own answer to how much of it no timesheet backs. | | `fixedPriceItemCount` | `integer` | yes | The fixed-price items that made it onto a line. Items are no entries. | | `fixedPriceAmountMinor` | `integer` | yes | Their total in minor units, a part of `subtotalMinor`. | | `excluded` | `InvoiceExclusion[]` | yes | | | `projectIds` | `string (uuid)[]` | yes | | | `periodStart` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `periodEnd` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### InvoiceDraftLine **Fields** | Field | Type | Required | Description | |---|---|---|---| | `position` | `integer` | yes | | | `kind` | `"time" \| "fixed_price"` | yes | `time` for a line built from tracked time or a monthly time allowance, `fixed_price` for one fixed-price item billed whole. A `fixed_price` line states no seconds: `quantitySeconds` and `quantityHours` are `null`, and `fixedPriceItemId` names the item. | | `description` | `string` | yes | | | `quantityHundredths` | `integer` | yes | The quantity as printed and priced, in hundredths of `unit` — `150` is `1,50 h`. Every line states one, and `unitRateMinor` is the price per `unit`. For a line built from tracked time it is `quantitySeconds` rounded to hundredths of an hour. `amountMinor` stays authoritative: it is quantity × price for invoices created since ADR-2026-10-08-14AU2, while an older invoice was priced from its exact seconds and may sit cents apart. | | `unit` | `string` | yes | The unit `quantityHundredths` and `unitRateMinor` are stated in; `h` for every line built from tracked time or a monthly time allowance, the item’s own unit (`Tag`, `pauschal`) for a fixed-price line. | | `quantitySeconds` | `integer \| null` | yes | The exact seconds a `time` line’s claims are measured against. `null` for a `fixed_price` line. | | `quantityHours` | `string \| null` | yes | Decimal hours of a `time` line; `null` for a `fixed_price` line. | | `fixedPriceItemId` | `string (uuid) \| null` | yes | The fixed-price item a `fixed_price` line bills; `null` for a `time` line. | | `unitRateMinor` | `integer` | yes | The price per `unit` in minor units. For a `time` line the hourly rate, exactly as it was frozen on the claims it is made of: the time entries for tracked hours, and its own claim for a monthly time allowance („Pauschale"); a line that is an allowance alone — what `entry` grouping produces for one — therefore states a rate no time entry backs. For a `fixed_price` line the item’s unit price, per day or per piece and not per hour. | | `amountMinor` | `integer` | yes | | | `currency` | `string` | yes | ISO-4217 currency code. | | `projectId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `taskId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `allowanceSeconds` | `integer` | yes | How many of `quantitySeconds` are a monthly time allowance („Pauschale") rather than tracked time — `0` for a line built from entries alone. At one resolved rate an allowance joins the line the person’s tracked hours are already on, so that line bills hours no time entry backs and the entries behind it are short by exactly this figure; under `entry` grouping it is instead a line of its own, where this equals `quantitySeconds` and `entryCount` is `0`. `entryCount` stays a count of entries either way. | | `entryCount` | `integer` | yes | How many time entries this line is made of. `0` is a real answer since a monthly time allowance („Pauschale") can be billed: under `entry` grouping a line stands for one entry and a month is not one, so an allowance gets a line of its own that no time entry backs — `allowanceSeconds` is then the whole of `quantitySeconds`. | ### InvoiceExclusion **Fields** | Field | Type | Required | Description | |---|---|---|---| | `reason` | `"not-billable" \| "fixed-price" \| "missing-rate" \| "already-invoiced" \| "outside-selection" \| "outside-period" \| "other-currency"` | yes | Why time or a fixed-price item inside the period is not on the invoice: the task is not billable, the task is billed through a fixed-price agreement (`fixed-price`: its hours are billed as the agreement’s items and never by the hour), no rate prices those seconds, another live invoice already bills them, a narrowed selection left them out, the period does not contain the whole month a monthly time allowance („Pauschale") is agreed for or the last day of a fixed-price item (`outside-period`), or a fixed-price item is in another currency than the invoice (`other-currency`). `missing-rate` is per second rather than per entry — hours a budget rule prices are billed while hours no rule prices are reported, for the same entry. `outside-selection` is nothing to fix: it is what a `budgetIds`/`taskIds` selection did not ask about, reported so that the hours still to be billed are visible. | | `entryCount` | `integer` | yes | How many entries contributed, counted once each. An entry only part of which a rate prices — hours held by two budgets, one of which no rule names — is on a line *and* here, for the seconds on either side; `durationSeconds` is the figure that adds up. | | `durationSeconds` | `integer` | yes | | | `allowanceSeconds` | `integer` | yes | How many of `durationSeconds` are allowance months rather than tracked time. Beside `entryCount` rather than inside it: a month is not a row anybody can go and look at. | | `fixedPriceItemCount` | `integer` | yes | How many fixed-price items were left out for this reason. Items are neither entries nor seconds, so they add to neither `entryCount` nor `durationSeconds`; `0` for every reason no item reached. | | `fixedPriceAmountMinor` | `integer` | yes | What those items would have billed, in the invoice’s currency and in minor units. Items in another currency add nothing, whatever the reason: currencies of their own are not added up. | | `tasks` | `InvoiceExclusionTask[]` | yes | The same figures per task, project then task, for `not-billable` (the task’s flag), `fixed-price` (its billing mode) and `missing-rate` (the project’s rules) — so the hours can be checked without opening the entries. Empty for every other reason. | ### InvoiceExclusionTask **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectName` | `string` | yes | | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `taskName` | `string` | yes | | | `entryCount` | `integer` | yes | | | `durationSeconds` | `integer` | yes | | | `allowanceSeconds` | `integer` | yes | | ### InvoiceExport **Fields** | Field | Type | Required | Description | |---|---|---|---| | `provider` | `InvoicingProvider` | yes | | | `documentId` | `string \| null` | yes | | | `exportedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `unclear` | `boolean` | yes | | | `documentLink` | `object \| null` | yes | | | `documentLink.url` | `string (uri)` | yes | | | `documentLink.providerName` | `string` | yes | | ### InvoiceLine **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `position` | `integer` | yes | | | `kind` | `"time" \| "fixed_price"` | yes | `time` for a line built from tracked time or a monthly time allowance, `fixed_price` for one fixed-price item billed whole. A `fixed_price` line states no seconds: `quantitySeconds` and `quantityHours` are `null`, and `fixedPriceItemId` names the item. | | `description` | `string` | yes | | | `quantityHundredths` | `integer` | yes | The quantity as printed and priced, in hundredths of `unit` — `150` is `1,50 h`. Every line states one, and `unitRateMinor` is the price per `unit`. For a line built from tracked time it is `quantitySeconds` rounded to hundredths of an hour. `amountMinor` stays authoritative: it is quantity × price for invoices created since ADR-2026-10-08-14AU2, while an older invoice was priced from its exact seconds and may sit cents apart. | | `unit` | `string` | yes | The unit `quantityHundredths` and `unitRateMinor` are stated in; `h` for every line built from tracked time or a monthly time allowance, the item’s own unit (`Tag`, `pauschal`) for a fixed-price line. | | `quantitySeconds` | `integer \| null` | yes | The exact seconds a `time` line’s claims are measured against. `null` for a `fixed_price` line. | | `quantityHours` | `string \| null` | yes | Decimal hours of a `time` line; `null` for a `fixed_price` line. | | `fixedPriceItemId` | `string (uuid) \| null` | yes | The fixed-price item a `fixed_price` line bills; `null` for a `time` line. | | `unitRateMinor` | `integer` | yes | The price per `unit` in minor units. For a `time` line the hourly rate, exactly as it was frozen on the claims it is made of: the time entries for tracked hours, and its own claim for a monthly time allowance („Pauschale"); a line that is an allowance alone — what `entry` grouping produces for one — therefore states a rate no time entry backs. For a `fixed_price` line the item’s unit price, per day or per piece and not per hour. | | `amountMinor` | `integer` | yes | | | `currency` | `string` | yes | ISO-4217 currency code. | | `projectId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `taskId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `entryCount` | `integer` | yes | How many time entries this line is made of. `0` is a real answer since a monthly time allowance („Pauschale") can be billed: under `entry` grouping a line stands for one entry and a month is not one, so an allowance gets a line of its own that no time entry backs — `allowanceSeconds` is then the whole of `quantitySeconds`. | | `allowanceSeconds` | `integer` | yes | How many of `quantitySeconds` are a monthly time allowance („Pauschale") rather than tracked time — `0` for a line built from entries alone. At one resolved rate an allowance joins the line the person’s tracked hours are already on, so that line bills hours no time entry backs and the entries behind it are short by exactly this figure; under `entry` grouping it is instead a line of its own, where this equals `quantitySeconds` and `entryCount` is `0`. `entryCount` stays a count of entries either way. | ### InvoiceList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `InvoiceListItem[]` | yes | | | `nextCursor` | `string \| null` | yes | Cursor for the next page, or null when the list is exhausted. | ### InvoiceListItem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `status` | `"draft" \| "transferred" \| "withdrawn"` | yes | 'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched. | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | | `periodStart` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `periodEnd` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `currency` | `string` | yes | ISO-4217 currency code. | | `subtotalMinor` | `integer` | yes | | | `notes` | `string \| null` | yes | | | `transferredAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `withdrawnAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `export` | `InvoiceExport` | yes | | | `transferredByHand` | `boolean` | yes | | | `document` | `InvoiceDocument` | yes | | | `recordedDocument` | `InvoiceRecordedDocument` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAtPrecise` | `string` | yes | | | `customerIcon` | `object \| null` | yes | | | `customerIcon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `customerIcon.contentType` | `"image/webp" \| "image/png"` | yes | | | `customerIcon.width` | `integer` | yes | | | `customerIcon.height` | `integer` | yes | | | `customerIcon.byteSize` | `integer` | yes | | | `customerIcon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `customerIcon.checksum` | `string` | yes | | ### InvoiceRecordedDocument **Fields** | Field | Type | Required | Description | |---|---|---|---| | `state` | `"issued" \| "sent" \| "overdue" \| "paid"` | yes | Read off the recorded days by Timjami, against today in the organization’s zone. The first that holds wins: `paid` a paid day is recorded; `overdue` the due day has passed; `sent` a sent day is recorded; `issued` none of these — the invoice was written, and that is all anybody said. | | `number` | `string \| null` | yes | The Rechnungsnummer as the invoicing program assigned it, copied by hand. Neither unique nor checked — Timjami assigns no number (ADR 0068). | | `documentDay` | `string \| null` | yes | The Rechnungsdatum. | | `dueDay` | `string \| null` | yes | The Fälligkeitsdatum. | | `duePassed` | `boolean` | yes | Whether `dueDay` is behind the organization's today — its own field for the reason the Easybill mirror has one: `paid` outranks `overdue` in `state`. | | `url` | `string \| null` | yes | A link to the document, always `https://`. | | `sentDay` | `string \| null` | yes | When it was sent to the customer. | | `paidDay` | `string \| null` | yes | When it was paid. | ### InvoiceStatusEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `status` | `"draft" \| "transferred" \| "withdrawn"` | yes | 'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched. | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `periodStart` | `string (date-time)` | yes | The start of the billed period. | | `periodEnd` | `string (date-time)` | yes | The end of the billed period, exclusive. | | `transferredAt` | `string (date-time) \| null` | yes | When this left Timjami for the invoicing tool. `null` for a draft. | | `withdrawnAt` | `string (date-time) \| null` | yes | When a transferred invoice was taken back into Timjami. `null` otherwise. | | `export` | `InvoiceStatusExport` | yes | | | `transferredByHand` | `boolean` | yes | Transferred while no invoicing tool was connected — the transfer is the whole settlement. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### InvoiceStatusExport **Fields** | Field | Type | Required | Description | |---|---|---|---| | `provider` | `InvoicingProvider` | yes | | | `exportedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### InvoiceStatusList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `InvoiceStatusEntry[]` | yes | | | `nextCursor` | `string \| null` | yes | Cursor for the next page, or null when the list is exhausted. | ### InvoiceWithLines **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `status` | `"draft" \| "transferred" \| "withdrawn"` | yes | 'draft' is Timjami's own — editable and deletable, and where the invoice is checked; 'transferred' means it has left Timjami for the invoicing tool, which is where a number, the VAT treatment and the sending happen — what the tool last said about them is `document`, which never changes this status; 'withdrawn' is a transferred invoice taken back here, which frees its hours to be billed again. Withdrawing is not a Storno: a document already in the invoicing tool is untouched. | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | | `periodStart` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `periodEnd` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `currency` | `string` | yes | ISO-4217 currency code. | | `subtotalMinor` | `integer` | yes | | | `notes` | `string \| null` | yes | | | `transferredAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `withdrawnAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `export` | `InvoiceExport` | yes | | | `transferredByHand` | `boolean` | yes | | | `document` | `InvoiceDocument` | yes | | | `recordedDocument` | `InvoiceRecordedDocument` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAtPrecise` | `string` | yes | | | `servicePeriodStart` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `servicePeriodEnd` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lines` | `InvoiceLine[]` | yes | | | `projects` | `object[]` | yes | | | `projects.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.name` | `string` | yes | | | `budgets` | `object[]` | yes | | | `budgets.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.name` | `string` | yes | | | `tasks` | `object[]` | yes | | | `tasks.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `tasks.name` | `string` | yes | | | `tasks.projectName` | `string` | yes | | ### InvoicingProvider Type: `"easybill" | "sevdesk"` ### InvoicingProviderConnection **Fields** | Field | Type | Required | Description | |---|---|---|---| | `provider` | `InvoicingProvider \| unknown \| null` | yes | The invoicing tool this organization has connected, or `null` when it bills by hand. At most one. | ### IosNotificationPushToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### IosNotificationPushTokenForget **Fields** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | ### IosNotificationPushTokenRegistration **Fields** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | ### JoinableProjectList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `data.projectName` | `string` | yes | | | `data.earliestMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `data.latestMonth` | `string \| null` | yes | A calendar month, `YYYY-MM`. | ### LinkHint **Fields** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryId` | `string (uuid) \| null` | yes | The time entry this hint became, or null to unlink. | ### LiveActivityStartToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### LiveActivityStartTokenRegistration **Fields** | Field | Type | Required | Description | |---|---|---|---| | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | ### LiveActivityUpdateToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `activityId` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### LiveActivityUpdateTokenRegistration **Fields** | Field | Type | Required | Description | |---|---|---|---| | `activityId` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | ### McpConsentDecision **Fields** | Field | Type | Required | Description | |---|---|---|---| | `redirectURI` | `string (uri)` | yes | Where the browser must be sent to hand the result back to the client. | ### McpConsentRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `clientName` | `string \| null` | yes | | | `redirectHost` | `string \| null` | yes | | | `scopes` | `string[]` | yes | | | `grants` | `object` | yes | | | `grants.hintsReadEnabled` | `boolean` | yes | | | `grants.budgetWriteEnabled` | `boolean` | yes | | | `grants.moneyReadEnabled` | `boolean` | yes | | | `grants.billingWriteEnabled` | `boolean` | yes | | | `unavailable` | `string[]` | yes | | ### McpGrant **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `clientId` | `string` | yes | The OAuth client id. A lookup key chosen by the client, never a label. | | `clientName` | `string \| null` | yes | The name chosen by the dynamically registered client. Advisory only. | | `redirectHost` | `string \| null` | yes | Host of the redirect URI verified during the most recent approval. | | `organization` | `object` | yes | | | `organization.id` | `string` | yes | | | `organization.name` | `string` | yes | | | `budgetWriteEnabled` | `boolean` | yes | Live second gate for ordinary budget writes. It never adds a scope to an access token. | | `canEnableBudgetWrite` | `boolean` | yes | Whether current membership and role allow the owner to turn budget writing on now. | | `moneyReadEnabled` | `boolean` | yes | Live second gate for OAuth money reads — invoices, their lines and amounts, write-offs, what is unbilled, and organization-wide revenue (invariant 79). It never adds a scope to an access token, and the current role still decides what each read answers. | | `canEnableMoneyRead` | `boolean` | yes | Whether the owner is still a member of the grant’s organization and may turn money reading on now. | | `billingWriteEnabled` | `boolean` | yes | Live second gate for what a client writes towards a bill: invoice drafts, monthly allowances and fixed-price work (ADR-2026-11-03-156MW). It never adds a scope to an access token, and the current role still decides what each write may do. | | `canEnableBillingWrite` | `boolean` | yes | Whether the owner is still a member of the grant’s organization and may turn billing writes on now. | | `hintsReadEnabled` | `boolean` | yes | Live second gate for reading your hints back through this client. Off unless turned on here or on the consent screen; the `thymian:hints.read` scope alone is not enough. | | `canEnableHintsRead` | `boolean` | yes | Whether the owner is still a member of the grant’s organization and may turn hint reading on now. | | `scopes` | `string[]` | yes | The Thymian scopes the client’s newest access token carries — what it was allowed on the consent screen. Empty once no token is left. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### Me **Fields** | Field | Type | Required | Description | |---|---|---|---| | `user` | `object` | yes | | | `user.id` | `string` | yes | | | `user.name` | `string` | yes | | | `user.email` | `string` | yes | | | `user.avatarUrl` | `string \| null` | yes | | | `user.image` **(deprecated)** | `string \| null` | yes | | | `user.avatar` | `ResourceIcon` | yes | | | `person` | `object \| null` | yes | | | `person.id` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `person.name` | `string` | yes | The organization’s name for this person — the account’s while it has one, the row’s own otherwise. | | `activeOrganizationId` | `string \| null` | yes | | | `preferences` | `UserPreferences` | yes | | | `organizationSettings` | `OrganizationSettings` | yes | | | `organizationFeatures` | `Feature[]` | yes | The features in effect for this caller in the active organization, including any an operator switched on for this account alone — a feature whose prerequisite is not on is left out. Empty without an active organization. | | `effectiveRateDisplay` | `"hourly" \| "daily"` | yes | How rates are presented in the UI. Does not affect stored data. | | `effectiveShowRunningTimerAvatars` | `boolean` | yes | | | `tourAvailable` | `boolean` | yes | | | `effectiveTourEnabled` | `boolean` | yes | | | `jamiAvailable` | `boolean` | yes | | | `effectiveJamiEnabled` | `boolean` | yes | | | `effectiveJamiNotesEnabled` | `boolean` | yes | Whether Jami shows this person his notes on what changed, such as the reorganized main menu. Needs the `jami-notes` feature in effect and the organization’s and the person’s Jami settings on, but not the tour. `false` without an active organization. | | `workSchedules` | `WorkSchedule[]` | yes | | | `isPlatformOperator` | `boolean` | yes | | | `canCreateOrganization` | `boolean` | yes | Whether this account may create an organization: a platform operator, or holding an unused invite-code redemption. | | `isImpersonating` | `boolean` | yes | | ### MemberAvatars **Fields** | Field | Type | Required | Description | |---|---|---|---| | `avatars` | `object[]` | yes | | | `avatars.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `avatars.avatarUrl` | `string` | yes | | | `avatars.avatar` **(deprecated)** | `object \| null` | yes | Deprecated uploaded-avatar metadata. Use `avatarUrl`, which also falls back to the identity-provider image. | ### MemberDailyReport **Fields** | Field | Type | Required | Description | |---|---|---|---| | `dailyOrgReportEmailEnabled` | `boolean` | yes | Whether a current holder of report.readOthers receives the daily organization revenue digest. | | `dailyOrgReportSendWeekdays` | `integer[]` | yes | ISO weekdays (1 = Monday … 7 = Sunday) the digest is sent on. The covered period is derived from this schedule, not stored separately. | | `dailyOrgReportSendHour` | `integer` | yes | The hour, in the organization's own time zone, the digest is sent at on a scheduled day. Only delays the send within that day — never changes which days it covers. | ### MemberGoogleAccounts **Fields** | Field | Type | Required | Description | |---|---|---|---| | `userIds` | `string[]` | yes | | ### MergeTimeEntries **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | | | `notes` | `string \| null` | no | | ### MoveBillingAllocation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `toProposalId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `reason` | `string \| null` | yes | | ### MoveCustomerPeriodClose **Fields** | Field | Type | Required | Description | |---|---|---|---| | `closedThroughDay` | `string \| null` | yes | The last day to close, inclusive — `null` opens everything again. A day later than the one stored is a **close** (owner, admin or `billing`); an earlier one, `null` included, is an **opening** (owner or admin) and needs `reason`. | | `reason` | `string` | no | Why the period is being opened again. **Required** — 422 with `reasonRequired` — for any backwards movement, and ignored for a close: closing is a statement of completeness, and the completeness is the reason. Kept with the movement, and shown in the project’s history. | | `projectIds` | `string (uuid)[]` | no | Only these projects of the customer, still in one transaction. Omitted means every project of the customer; an id that is not one of them is 422. | ### MovePeriodClose **Fields** | Field | Type | Required | Description | |---|---|---|---| | `closedThroughDay` | `string \| null` | yes | The last day to close, inclusive — `null` opens everything again. A day later than the one stored is a **close** (owner, admin or `billing`); an earlier one, `null` included, is an **opening** (owner or admin) and needs `reason`. | | `reason` | `string` | no | Why the period is being opened again. **Required** — 422 with `reasonRequired` — for any backwards movement, and ignored for a close: closing is a statement of completeness, and the completeness is the reason. Kept with the movement, and shown in the project’s history. | ### OperatorFeature Type: `"harvest" | "company-metrics" | "agent-usage" | "day-export" | "tour" | "jami" | "jami-notes" | "max-view" | "entry-split"` ### OrganizationDashboard **Fields** | Field | Type | Required | Description | |---|---|---|---| | `asOf` | `string (date-time)` | yes | The single instant running timers were measured at. | | `day` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `weekFromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `monthFromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `currency` | `string` | yes | ISO-4217 currency code. | | `revenue` | `object` | yes | | | `revenue.today` | `object` | yes | | | `revenue.week` | `object` | yes | | | `revenue.month` | `object` | yes | | | `attendanceToday` | `object` | yes | Today's tracked and billable seconds summed across every member — the same organization-wide scope as `people`, computed once here rather than by every client. | | `attendanceToday.trackedSeconds` | `integer` | yes | | | `attendanceToday.billableSeconds` | `integer` | yes | | | `customers` | `object[]` | yes | | | `customers.customerId` | `string (uuid)` | yes | | | `customers.customerName` | `string` | yes | | | `customers.revenue` | `object` | yes | | | `people` | `object[]` | yes | | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `people.name` | `string` | yes | | | `people.avatarUrl` | `string \| null` | yes | The final profile picture URL: the person's Timjami avatar, otherwise the identity provider's picture. | | `people.image` **(deprecated)** | `string \| null` | yes | Deprecated compatibility alias for `avatarUrl`. | | `people.scheduledSeconds` | `integer` | yes | The work schedule demand before absences are applied. | | `people.absentSeconds` | `integer` | yes | | | `people.expectedSeconds` | `integer` | yes | Today's target after absences are applied. | | `people.trackedSeconds` | `integer` | yes | Tracked seconds including a running timer measured at `asOf`. | | `people.billableSeconds` | `integer` | yes | Of `trackedSeconds`, the seconds on billable tasks — task billability, not whether a rate resolved. | | `people.hasAbsence` | `boolean` | yes | | | `people.isRunning` | `boolean` | yes | | | `people.tasks` | `object[]` | yes | | | `notStartedPersonIds` | `string (uuid)[]` | yes | Scheduled members with zero tracked seconds and no absence covering the day. | ### OrganizationDomain **Fields** | Field | Type | Required | Description | |---|---|---|---| | `domain` | `string` | yes | | | `verificationHost` | `string` | yes | | | `verificationValue` | `string` | yes | | | `verifiedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `lastCheckedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `verificationLostAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `autoJoinEnabled` | `boolean` | yes | | ### OrganizationFeature Type: `"planning" | "customer-portal" | "tickets" | "absences"` ### OrganizationFeatureSwitches **Fields** | Field | Type | Required | Description | |---|---|---|---| | `planning` | `boolean` | yes | | | `customer-portal` | `boolean` | yes | | | `tickets` | `boolean` | yes | | | `absences` | `boolean` | yes | | ### OrganizationIcon **Fields** | Field | Type | Required | Description | |---|---|---|---| | `icon` | `ResourceIcon` | yes | | ### OrganizationLoginPolicy **Fields** | Field | Type | Required | Description | |---|---|---|---| | `enabled` | `boolean` | yes | | | `enabledAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `enabledByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `lastDisabledAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `lastDisabledReason` | `"owner" \| "domain-verification-lost" \| null \| null` | yes | | | `domainVerified` | `boolean` | yes | | | `viewerHasGoogleAccount` | `boolean` | yes | | ### OrganizationMembers **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.memberId` | `string \| null` | yes | better-auth’s membership id, which a role change addresses. `null` for a person who holds no access grant in this organization — somebody an import created, or somebody who has left. `role` and `joinedAt` are `null` with it. | | `data.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `data.userId` | `string \| null` | yes | The **account** this person signs in with — the join key for the account-keyed reads (`GET /organization/member-google-accounts`). Never a subject: pass `personId` for that. `null` for a person with no account, whether it was deleted or never existed. | | `data.role` | `string \| null` | yes | May be comma-joined (e.g. "admin,member") — never compare with equality. `null` when `memberId` is. | | `data.name` | `string` | yes | | | `data.email` | `string` | yes | | | `data.joinedAt` | `string (date-time) \| null` | yes | When the membership began. `null` when `memberId` is. | ### OrganizationRunningTimers **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entries` | `object[]` | yes | | | `entries.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `entries.task` | `object \| null` | yes | | | `entries.project` | `object \| null` | yes | | | `entries.customer` | `object` | yes | | ### OrganizationSettings **Fields** | Field | Type | Required | Description | |---|---|---|---| | `organizationId` | `string` | yes | | | `currency` | `string` | yes | ISO-4217 currency code. | | `rateDisplay` | `"hourly" \| "daily"` | yes | How rates are presented in the UI. Does not affect stored data. | | `hoursPerDay` | `string` | yes | | | `timeZone` | `string` | yes | | | `weekStartsOn` | `integer` | yes | | | `hintRetentionDays` | `integer` | yes | Days a hint is kept before `POST /hints` prunes it. Enforced when a collector reports, not by a scheduler. | | `runningTimerAvatarsEnabled` | `boolean` | yes | Whether the header shows avatars of colleagues whose timer is running. The organization-wide default; a member may still turn it off for themselves. | | `tourEnabled` | `boolean` | yes | Whether the web app's onboarding tour may appear for anybody in this organization. A member may still turn it off for themselves; a member's own setting never overrides this being off. Has no effect while the organization does not have the `tour` feature (`GET /me` `organizationFeatures`), and cannot be changed to `true` then. | | `jamiEnabled` | `boolean` | yes | Whether Jami appears for anybody in this organization, with the same levels as `tourEnabled`. Under the `jami` feature, which takes effect only together with `tour`, it decides whether he guides the tour; without him the tour speaks in neutral text and draws no figure. Under `jami-notes` it decides whether his notes on what changed appear; off, they reach nobody here. Has no effect while neither feature is in effect, and cannot be changed to `true` then. | | `trackingOpenMonths` | `integer \| null` | yes | How many completed months stay open for tracking beside the current one, or `null` for no limit at all. Written by `PUT /organization/tracking-window`, never here. | | `earliestOpenDay` | `string \| null` | yes | What `trackingOpenMonths` currently means: the first day hours may still be recorded on, or `null` where no limit is set. **Derived, and this is what every refusal compares against** — a client never re-derives it from the number of months. It moves at a month turn with nobody acting, so a page open across one holds a stale value; the write path refuses regardless. | | `llmHintEvaluationEnabled` | `boolean` | yes | Whether administrators permit manual LLM evaluation of hints. | | `billingEscalationDelayDays` | `integer` | yes | Written by `PUT /organization/billing-escalation`, never here. | | `billingEscalationRecipientPersonId` | `string (uuid) \| null` | yes | Written by `PUT /organization/billing-escalation`, never here. | ### OtlpExportResponse Type: `unknown` ### OtlpLogsRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `resourceLogs` | `object[] \| null` | no | | | `resourceLogs.resource` | `object \| null` | no | | | `resourceLogs.scopeLogs` | `object[] \| null` | no | | ### PeriodCloseChange **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `fromDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `toDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `reason` | `string \| null` | yes | | | `movedByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `movedByName` | `string \| null` | yes | | | `movedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### PeriodCloseResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projects` | `object[]` | yes | | | `projects.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.moved` | `boolean` | yes | | | `projects.fromDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `projects.closedThroughDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | ### Person **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | The person’s stable id — the same value every `personId` field of this API carries, time entries included. Never a login account id. | | `displayName` | `string` | yes | The linked account’s name while there is one, otherwise the name Timjami keeps for the person. | | `email` | `string \| null` | yes | The linked account’s address, otherwise an address recorded when the person was imported (never verified), otherwise `null`. | | `status` | `"active" \| "inactive"` | yes | `active` while the person has access to this organization — a live membership whose account is not banned. `inactive` otherwise: the membership ended, the account is banned, or the person never had access (for example one imported from Harvest). An inactive person is still listed; historical time may name them. | ### PersonActivity **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `from` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `until` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `totalSeconds` | `integer` | yes | | | `billableSeconds` | `integer` | yes | | | `entryCount` | `integer` | yes | | | `daysWorked` | `integer` | yes | | | `perDay` | `PersonActivityDay[]` | yes | | | `projects` | `PersonActivityProject[]` | yes | | ### PersonActivityDay **Fields** | Field | Type | Required | Description | |---|---|---|---| | `day` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `totalSeconds` | `integer` | yes | | | `billableSeconds` | `integer` | yes | | | `projects` | `PersonActivityDayProject[]` | yes | | | `tasks` | `PersonActivityDayTask[]` | yes | | ### PersonActivityDayProject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | | | `totalSeconds` | `integer` | yes | | | `billableSeconds` | `integer` | yes | | ### PersonActivityDayTask **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | | | `projectId` | `string (uuid)` | yes | | | `totalSeconds` | `integer` | yes | | | `billableSeconds` | `integer` | yes | | ### PersonActivityNote **Fields** | Field | Type | Required | Description | |---|---|---|---| | `text` | `string` | yes | | | `entryCount` | `integer` | yes | | | `totalSeconds` | `integer` | yes | | | `firstDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `lastDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | ### PersonActivityProject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | | | `customerName` | `string` | yes | | | `projectId` | `string (uuid)` | yes | | | `projectName` | `string` | yes | | | `totalSeconds` | `integer` | yes | | | `billableSeconds` | `integer` | yes | | | `tasks` | `PersonActivityTask[]` | yes | | ### PersonActivityTask **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | | | `taskName` | `string` | yes | | | `isBillable` | `boolean` | yes | | | `totalSeconds` | `integer` | yes | | | `noteCount` | `integer` | yes | | | `notesTruncated` | `boolean` | yes | | | `notes` | `PersonActivityNote[]` | yes | | ### PersonioAttendanceExport **Fields** | Field | Type | Required | Description | |---|---|---|---| | `active` | `boolean` | yes | Whether the export writes attendances: its sync target exists and is enabled. | ### PersonioPersonMappings **Fields** | Field | Type | Required | Description | |---|---|---|---| | `persons` | `object[]` | yes | | | `persons.id` | `string (uuid)` | yes | | | `persons.name` | `string` | yes | | | `persons.email` | `string \| null` | yes | | | `persons.status` | `"assigned" \| "open" \| "ambiguous"` | yes | | | `persons.assignment` | `object \| null` | yes | | | `persons.suggestions` | `object[]` | yes | | | `personioPersons` | `object[]` | yes | | | `personioPersons.id` | `string (uuid)` | yes | | | `personioPersons.name` | `string` | yes | | | `personioPersons.email` | `string \| null` | yes | | | `personioPersons.assignedTo` | `string \| null` | yes | | | `openCount` | `integer` | yes | Persons read by the sync service without a confirmed, live counterpart. Includes ambiguous suggestions; these persons cannot resolve in the export. | ### PersonRevenue **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `from` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `until` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `currency` | `string` | yes | ISO-4217 currency code. | | `amountMinor` | `integer` | yes | The sum of every priced billable slice, at current rates — provisional until an invoice is transferred, and zero rather than omitted when nothing was priced. | | `unratedSeconds` | `integer` | yes | Billable seconds no rate resolved for; reported rather than valued at zero. | | `runningIncluded` | `boolean` | yes | True when `includeRunning` was asked for and the subject's own running entry falls inside this window. | ### PlannedPreviewPeriod **Fields** | Field | Type | Required | Description | |---|---|---|---| | `periodStartMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `plannedSeconds` | `integer` | yes | Planned on projects the rule covers, budgets whose every project it covers, or — for an all-projects rule — the customer itself. Intent, never recorded time (ADR-2026-09-18-13VXS). | | `unassignedSeconds` | `integer` | yes | Planned on this customer but not attributable to the rule’s projects: a customer-level plan under a selected-projects rule, or a budget that also reaches other projects. | ### PlanningAllocation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `budgetId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `plannedSeconds` | `integer` | yes | Whole seconds planned for this person, target and week. Always positive — a cell set to `0:00` removes the row instead of storing it, so „nothing planned" is a missing allocation rather than one saying nothing. | | `note` | `string \| null` | yes | | | `expectedRemainingSeconds` | `integer \| null` | yes | A person’s manual correction of what is still to do in the current week, or null (usually). Only the input: the effective figure, corrected or derived, is `remainingPlannedSeconds`. `0` means done. The name predates TIM-234. | | `expectedRemainingUpdatedAt` | `string (date-time) \| null` | yes | When `expectedRemainingSeconds` was last set; null without a correction. | | `createdByPersonId` | `string (uuid) \| null` | yes | Who first set this cell. Null once that person was deleted — the row outlives them. | | `updatedByPersonId` | `string (uuid) \| null` | yes | Who last wrote this cell — the same as `createdByPersonId` until a second write changes it. Null once that person was deleted. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### PlanningContext **Fields** | Field | Type | Required | Description | |---|---|---|---| | `targetType` | `"customer" \| "person" \| "project" \| "budget"` | yes | | | `targetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `targetName` | `string` | yes | | | `notes` | `string` | yes | | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | ### PlanningContextList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `contexts` | `PlanningContext[]` | yes | | ### PlanningForecast **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currentWeekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `fromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `untilMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `weeks` | `object[]` | yes | | | `weeks.weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `weeks.month` | `string` | yes | A calendar month, `YYYY-MM`. | | `customers` | `object[]` | yes | | | `customers.color` | `string` | yes | | | `customers.icon` | `ResourceIcon` | yes | | | `customers.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customers.name` | `string` | yes | | | `customers.archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `projects` | `object[]` | yes | | | `projects.icon` | `ResourceIcon` | yes | | | `projects.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.name` | `string` | yes | | | `projects.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.customerName` | `string` | yes | | | `projects.color` | `string \| null` | yes | | | `projects.archivedAt` | `string (date-time) \| null` | yes | Set when the project or its customer is archived. Still named here: archiving prevents new booking, never historical naming (invariant 54), and an archived project may still carry allocations. | | `budgets` | `object[]` | yes | | | `budgets.budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.name` | `string` | yes | | | `budgets.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.kind` | `"monthly" \| "total"` | yes | | | `budgets.projectIds` | `string (uuid)[]` | yes | | | `budgets.validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `budgets.validUntilMonth` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `budgets.months` | `object[]` | yes | | | `budgets.archivedAt` | `string (date-time) \| null` | no | ISO-8601 timestamp with offset. | | `budgets.customerName` | `string` | yes | | | `budgets.total` | `object` | yes | | | `allocations` | `PlanningOverviewAllocation & object[]` | yes | | | `issues` | `object[]` | yes | | | `issues.kind` | `"over-budget" \| "ending-agreement" \| "uncovered-work"` | yes | | | `issues.budgetId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `issues.allocationId` | `string (uuid) \| null` | yes | For `uncovered-work`, its one allocation; otherwise null. Redundant with `allocationIds`, kept for existing clients (TIM-234). | | `issues.allocationIds` | `string (uuid)[]` | yes | The allocations whose remaining planned seconds make up `seconds`: for an ending agreement, the weeks planned after it ends; for uncovered work, its one allocation; empty for over-budget. | | `issues.month` | `string` | yes | A calendar month, `YYYY-MM`. | | `issues.seconds` | `integer` | yes | | ### PlanningOverview **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currentWeekStartDay` | `string` | yes | The Monday of the ISO week containing „now", in the organization’s time zone — what the grid tints as the current week and where the forecast starts crediting tracked work against the plan. | | `weeks` | `object[]` | yes | Every week in the resolved range, in order, each tagged with the calendar month its Thursday falls in (`monthOfWeek`) — the same rule the budget figures below use, so the grid’s header and the arithmetic never disagree about the same week. | | `weeks.weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `weeks.month` | `string` | yes | A calendar month, `YYYY-MM`. | | `people` | `object[]` | yes | | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `people.weeks` | `object[]` | yes | | | `allocations` | `PlanningOverviewAllocation[]` | yes | Every stored allocation whose week falls in the requested range, any person or project. | | `tracked` | `object[]` | yes | | | `tracked.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `tracked.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `tracked.weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `tracked.trackedSeconds` | `integer` | yes | Finished entries’ seconds for this person, project and week, bucketed by `started_at` in the organization’s time zone. Never a plan figure, and a plan figure never adds to it (invariant 70). | | `customers` | `object[]` | yes | | | `customers.color` | `string` | yes | | | `customers.icon` | `ResourceIcon` | yes | | | `customers.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customers.name` | `string` | yes | | | `customers.archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `budgetWeeks` | `object[]` | yes | Weekly forecast allocations: direct budget intent plus project intent uniquely assigned to this budget. Not the sum of direct editable rows alone. | | `budgetWeeks.budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgetWeeks.weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `budgetWeeks.plannedSeconds` | `integer` | yes | | | `customerWeeks` | `object[]` | yes | | | `customerWeeks.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerWeeks.weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `customerWeeks.plannedSeconds` | `integer` | yes | | | `customerWeeks.trackedSeconds` | `integer` | yes | | | `customerWeeks.unassignedSeconds` | `integer` | yes | Planned seconds for this customer with no unique budget assignment. | | `trackedCustomers` | `object[]` | yes | | | `trackedCustomers.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `trackedCustomers.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `trackedCustomers.weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `trackedCustomers.trackedSeconds` | `integer` | yes | | | `projects` | `object[]` | yes | Every project named by an allocation, a tracked row or a budget above — archived projects included, because their names must still travel (invariant 54). | | `projects.icon` | `ResourceIcon` | yes | | | `projects.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.name` | `string` | yes | | | `projects.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.customerName` | `string` | yes | | | `projects.color` | `string \| null` | yes | | | `projects.archivedAt` | `string (date-time) \| null` | yes | Set when the project or its customer is archived. Still named here: archiving prevents new booking, never historical naming (invariant 54), and an archived project may still carry allocations. | | `budgets` | `object[]` | yes | | | `budgets.budgetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.name` | `string` | yes | | | `budgets.customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `budgets.kind` | `"monthly" \| "total"` | yes | | | `budgets.projectIds` | `string (uuid)[]` | yes | | | `budgets.validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `budgets.validUntilMonth` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `budgets.months` | `object[]` | yes | | | `budgets.archivedAt` | `string (date-time) \| null` | no | ISO-8601 timestamp with offset. | ### PlanningOverviewAllocation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `budgetId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `plannedSeconds` | `integer` | yes | Whole seconds planned for this person, target and week. Always positive — a cell set to `0:00` removes the row instead of storing it, so „nothing planned" is a missing allocation rather than one saying nothing. | | `note` | `string \| null` | yes | | | `expectedRemainingSeconds` | `integer \| null` | yes | A person’s manual correction of what is still to do in the current week, or null (usually). Only the input: the effective figure, corrected or derived, is `remainingPlannedSeconds`. `0` means done. The name predates TIM-234. | | `expectedRemainingUpdatedAt` | `string (date-time) \| null` | yes | When `expectedRemainingSeconds` was last set; null without a correction. | | `createdByPersonId` | `string (uuid) \| null` | yes | Who first set this cell. Null once that person was deleted — the row outlives them. | | `updatedByPersonId` | `string (uuid) \| null` | yes | Who last wrote this cell — the same as `createdByPersonId` until a second write changes it. Null once that person was deleted. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `forecastBudgetId` | `string (uuid) \| null` | yes | Uniquely assigned forecast budget for this week, or null. Never changes the stored planning target or actual consumption. | | `matchedActualSeconds` | `integer` | yes | Finished billable seconds this person tracked in this (current) week that are credited to this plan, each second at most once. | | `remainingPlannedSeconds` | `integer` | yes | What is still to do on this plan: the whole plan for a future week, plan minus `matchedActualSeconds` (or the manual `expectedRemainingSeconds`) for the current week, `0` for a past week. | | `unassignedReason` | `"customer-target" \| "missing-target" \| "archived-target" \| "outside-agreement" \| "no-budget" \| "ambiguous-budget" \| null \| null` | yes | | | `createdByName` | `string \| null` | yes | | | `updatedByName` | `string \| null` | yes | | ### PlanningReadersAwaitingAbsences **Fields** | Field | Type | Required | Description | |---|---|---|---| | `persons` | `object[]` | yes | | | `persons.id` | `string (uuid)` | yes | | | `persons.name` | `string` | yes | | ### PlatformAccessLog **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entries` | `PlatformAccessLogEntry[]` | yes | | ### PlatformAccessLogEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | | `actorUserId` | `string` | yes | | | `actorName` | `string` | yes | | | `actorEmail` | `string` | yes | | | `action` | `string` | yes | | | `organizationId` | `string \| null` | yes | | | `organizationName` | `string \| null` | yes | | | `recordedAt` | `string (date-time)` | yes | | ### PlatformAppInstallation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `installationId` | `string (uuid)` | yes | | | `app` | `string` | yes | | | `version` | `string` | yes | | | `platform` | `string` | yes | | | `architecture` | `string` | yes | | | `firstSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### PlatformAppVersions **Fields** | Field | Type | Required | Description | |---|---|---|---| | `activeWindowDays` | `integer` | yes | | | `versions` | `object[]` | yes | | | `versions.app` | `string` | yes | | | `versions.version` | `string` | yes | | | `versions.outdated` | `boolean` | yes | | | `versions.recentInstallations` | `integer` | yes | | | `versions.knownInstallations` | `integer` | yes | | | `versions.lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `versions.users` | `object[]` | yes | | ### PlatformIosNotificationPushToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `organizationId` | `string` | yes | | | `installationId` | `string (uuid)` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### PlatformLiveActivityStartToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `organizationId` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### PlatformLiveActivityUpdateToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `organizationId` | `string` | yes | | | `activityId` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### PlatformOrganization **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string` | yes | | | `slug` | `string` | yes | | | `icon` | `ResourceIcon` | yes | | | `createdAt` | `string (date-time)` | yes | | | `memberCount` | `integer` | yes | | | `lastActivityAt` | `string (date-time) \| null` | yes | | | `trackedSecondsLast7Days` | `integer` | yes | | | `trackedSecondsPrevious7Days` | `integer` | yes | | | `activePeopleLast7Days` | `integer` | yes | | | `excludedFromAnalytics` | `boolean` | yes | | ### PlatformOrganizationDetail **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string` | yes | | | `slug` | `string` | yes | | | `icon` | `ResourceIcon` | yes | | | `createdAt` | `string (date-time)` | yes | | | `memberCount` | `integer` | yes | | | `lastActivityAt` | `string (date-time) \| null` | yes | | | `trackedSecondsLast7Days` | `integer` | yes | | | `trackedSecondsPrevious7Days` | `integer` | yes | | | `activePeopleLast7Days` | `integer` | yes | | | `excludedFromAnalytics` | `boolean` | yes | | | `members` | `PlatformOrganizationMember[]` | yes | | | `features` | `OperatorFeature[]` | yes | | | `switchedOff` | `OrganizationFeature[]` | yes | | ### PlatformOrganizationMember **Fields** | Field | Type | Required | Description | |---|---|---|---| | `status` | `"active" \| "invited"` | yes | | | `userId` | `string \| null` | yes | | | `name` | `string \| null` | yes | | | `email` | `string` | yes | | | `role` | `string` | yes | | | `lastLoginAt` | `string (date-time) \| null` | yes | | | `invitedAt` | `string (date-time) \| null` | yes | | | `invitationExpired` | `boolean` | yes | | | `appInstallations` | `PlatformAppInstallation[]` | yes | | | `features` | `OperatorFeature[]` | yes | | ### PlatformOrganizations **Fields** | Field | Type | Required | Description | |---|---|---|---| | `organizations` | `PlatformOrganization[]` | yes | | ### PlatformProductRelease **Fields** | Field | Type | Required | Description | |---|---|---|---| | `version` | `string \| null` | yes | | | `tag` | `string \| null` | yes | | | `releaseUrl` | `string (uri) \| null` | yes | | | `releaseWorkflow` | `object \| null` | yes | | | `releaseWorkflow.status` | `string` | yes | | | `releaseWorkflow.conclusion` | `string \| null` | yes | | | `releaseWorkflow.htmlUrl` | `string (uri)` | yes | | | `releaseWorkflow.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `releaseWorkflow.headSha` | `string` | yes | | | `releaseWorkflow.headCommitMessage` | `string \| null` | yes | | | `activeReleaseWorkflows` | `PlatformWorkflowRun[]` | yes | | ### PlatformPushLogEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `timestamp` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `level` | `"debug" \| "info" \| "warn" \| "error" \| "fatal"` | yes | | | `event` | `string` | yes | | | `message` | `string` | yes | | | `outcome` | `string \| null` | yes | | | `pushType` | `string \| null` | yes | | | `tokenKind` | `string \| null` | yes | | | `platform` | `string \| null` | yes | | | `kind` | `string \| null` | yes | | | `activityId` | `string \| null` | yes | | | `reason` | `string \| null` | yes | | | `detail` | `string \| null` | yes | | ### PlatformPushLogs **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entries` | `PlatformPushLogEntry[]` | yes | | | `truncated` | `boolean` | yes | | ### PlatformPushTokens **Fields** | Field | Type | Required | Description | |---|---|---|---| | `iosNotificationTokens` | `PlatformIosNotificationPushToken[]` | yes | | | `widgetTokens` | `PlatformWidgetPushToken[]` | yes | | | `liveActivityStartToken` | `PlatformLiveActivityStartToken` | yes | | | `liveActivityUpdateTokens` | `PlatformLiveActivityUpdateToken[]` | yes | | ### PlatformReleases **Fields** | Field | Type | Required | Description | |---|---|---|---| | `web` | `object` | yes | | | `web.deployWorkflow` | `object \| null` | yes | | | `web.activeDeployWorkflows` | `PlatformWorkflowRun[]` | yes | | | `web.pendingCiWorkflows` | `PlatformWorkflowRun[]` | yes | | | `web.blockingCiWorkflow` | `object \| null` | yes | | | `cli` | `PlatformProductRelease` | yes | | | `desktop` | `PlatformProductRelease` | yes | | | `extension` | `PlatformProductRelease` | yes | | | `safariExtension` | `PlatformProductRelease` | yes | | | `ios` | `PlatformProductRelease` | yes | | | `raycast` | `PlatformProductRelease` | yes | | ### PlatformUsage **Fields** | Field | Type | Required | Description | |---|---|---|---| | `windowDays` | `integer` | yes | | | `areas` | `object[]` | yes | | | `areas.area` | `"time-entries" \| "ticket-references" \| "breaks" \| "absences" \| "work-schedules" \| "period-close" \| "day-tab-calendar" \| "day-tab-max" \| "invoices" \| "recurring-billing" \| "fixed-price" \| "time-allowances" \| "budgets" \| "budget-reports" \| "planning" \| "planning-notes" \| "customer-portal" \| "dashboards" \| "company-metrics" \| "agent-usage" \| "hints" \| "mcp" \| "slack" \| "easybill" \| "sevdesk" \| "google-calendar" \| "harvest" \| "app-desktop" \| "app-cli" \| "app-ios" \| "app-raycast" \| "app-browser-extension"` | yes | | | `areas.kind` | `"activity" \| "setup" \| "held"` | yes | | | `areas.organizationsUsing` | `integer` | yes | | | `areas.organizationsEligible` | `integer` | yes | | | `organizations` | `object[]` | yes | | | `organizations.id` | `string` | yes | | | `organizations.name` | `string` | yes | | | `organizations.slug` | `string` | yes | | | `organizations.icon` | `ResourceIcon` | yes | | | `organizations.notInEffect` | `("time-entries" \| "ticket-references" \| "breaks" \| "absences" \| "work-schedules" \| "period-close" \| "day-tab-calendar" \| "day-tab-max" \| "invoices" \| "recurring-billing" \| "fixed-price" \| "time-allowances" \| "budgets" \| "budget-reports" \| "planning" \| "planning-notes" \| "customer-portal" \| "dashboards" \| "company-metrics" \| "agent-usage" \| "hints" \| "mcp" \| "slack" \| "easybill" \| "sevdesk" \| "google-calendar" \| "harvest" \| "app-desktop" \| "app-cli" \| "app-ios" \| "app-raycast" \| "app-browser-extension")[]` | yes | | | `organizations.usage` | `object[]` | yes | | ### PlatformWidgetPushToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `organizationId` | `string` | yes | | | `platform` | `"ios" \| "watchos"` | yes | | | `kind` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### PlatformWorkflowRun **Fields** | Field | Type | Required | Description | |---|---|---|---| | `status` | `string` | yes | | | `conclusion` | `string \| null` | yes | | | `htmlUrl` | `string (uri)` | yes | | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `headSha` | `string` | yes | | | `headCommitMessage` | `string \| null` | yes | | ### PolicyBlocker Type: `object | object | object` ### PreviewAllowanceWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `months` | `AllowanceWriteOffMonthRequest[]` | yes | The whole allowance months to write off — never a fraction of one. Already-settled months (by an invoice or another write-off) answer 409; any unpriced share of a month refuses the whole month with 422. | ### PreviewInvoice **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectIds` | `string (uuid)[]` | no | | | `budgetIds` | `string (uuid)[]` | no | The budgets to bill, or empty for every pot. Exactly the pots named, never their overflow chain. Seconds in other pots are reported as `outside-selection` rather than dropped. | | `taskIds` | `string (uuid)[]` | no | The tasks to bill, or empty for every task of the selected projects. Hours on other tasks are reported as `outside-selection`. | | `from` | `string (date-time) \| null` | yes | Inclusive lower bound on the entry start, or null for no lower bound. | | `until` | `string (date-time)` | yes | Exclusive upper bound on the entry start. | | `grouping` | `"member" \| "project" \| "task" \| "entry"` | yes | What one line of the invoice stands for: `member` one line per person, `project` per project, `task` per task, `entry` one line per time entry. Entries recorded at different rates always land on different lines, whatever the grouping — a line never averages two rates. | ### PreviewProjectWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `from` | `string` | no | Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. | | `until` | `string` | no | Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. | ### PreviewWriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | The entries to write off. Their **free** seconds are what gets waived — an entry partly on an invoice contributes only its unbilled rest. A running entry, and a selection with nothing free in it, are refused with 422; an id this organization does not have (or on a project you may not see) is a 404. | ### PricedPreviewHistory **Fields** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"catch-up" \| "period"` | yes | `catch-up`: the one proposal `collect-history` makes. `period`: one of the proposals `separate-periods` makes. | | `periodStartMonth` | `string` | yes | The first period with something open. | | `lastMonth` | `string` | yes | The range’s last month, inclusive. | | `seconds` | `integer` | yes | Seconds the proposal would hold, priced or not — everybody’s, organization-wide. 0 when it holds only fixed-price items. | | `amountMinor` | `integer \| null` | yes | The invoice’s subtotal in minor units, rounded once per line and provisional until invoiced. Only what setup takes on counts, so months another rule owns stay out. `null` when more than 24 periods would have to be priced. | | `missingRateSeconds` | `integer \| null` | yes | Billable seconds in the range no rate prices. `null` with `amountMinor`. | | `otherCurrencyItemCount` | `integer \| null` | yes | Completed fixed-price items in another currency, which the proposal holds and `amountMinor` leaves out. `null` with `amountMinor`. | ### PricedPreviewPeriod **Fields** | Field | Type | Required | Description | |---|---|---|---| | `periodStartMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `lastMonth` | `string` | yes | The period’s last month, inclusive. | | `dueDay` | `string (date)` | yes | | | `state` | `"ready" \| "running" \| "upcoming"` | yes | `ready`: ended, becomes a proposal once the rule is saved if `proposes` says so. `running`: holds the current month. `upcoming`: starts within the next twelve months. | | `seconds` | `integer \| null` | yes | Seconds the proposal would hold, priced or not — everybody’s, organization-wide. A `running` period counts up to now. `null` when `upcoming`. | | `amountMinor` | `integer \| null` | yes | That invoice’s subtotal in minor units, rounded once per line as the invoice would be, and provisional until an invoice claims the seconds. `null` when `upcoming`: a future period is never priced. | | `missingRateSeconds` | `integer \| null` | yes | Billable seconds in the period that no rate prices. `null` when `upcoming`. | | `otherCurrencyItemCount` | `integer \| null` | yes | Completed fixed-price items in another currency, which the proposal holds and `amountMinor` leaves out. `null` when `upcoming`. | | `proposes` | `boolean` | yes | Whether the rule leads to a proposal for this period. A period that ended before the rule exists gets one only when it holds something to bill; a later one always does. | ### PricingBudget **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `validUntilMonth` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `entersDirectly` | `boolean` | yes | Whether this project’s hours enter this budget directly, or whether it is only reachable from it by overflow. Both can be priced; the difference is what a picker has to say to be readable. | ### Problem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `type` | `string` | yes | | | `title` | `string` | yes | | | `status` | `integer` | yes | | | `detail` | `string` | no | | | `errors` | `object[]` | no | Field-level errors, present on validation problems. | | `errors.field` | `string` | yes | | | `errors.message` | `string` | yes | | | `requestId` | `string (uuid)` | no | Correlates this response with the API log lines it produced. Absent on error bodies better-auth's own endpoints answer, which do not pass through this API's error handler. | ### Project **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `aliases` | `string[]` | yes | Alternative names. They are included in resource search and when filtering task pickers. | | `rules` | `RateRule[]` | yes | The project’s billing rules, least specific first. Empty means nothing on this project has a rate. | | `visibility` | `"public" \| "private"` | yes | Who may see the project. `public` is the whole organization. | | `viewerMayWrite` | `boolean` | yes | | | `memberCount` | `integer` | yes | | | `color` | `string \| null` | yes | | | `icon` | `ResourceIcon` | yes | | | `iconInheritedFrom` | `VisualProvenance` | yes | | | `colorInheritedFrom` | `VisualProvenance` | yes | | | `archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `closedThroughDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### ProjectAdditionPeriod **Fields** | Field | Type | Required | Description | |---|---|---|---| | `periodStartMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `lastMonth` | `string` | yes | The period’s last month, inclusive. | | `state` | `"ready" \| "running"` | yes | `ready`: ended, its hours can be billed now in a draft of their own. `running`: the hours go with the period’s regular invoice. | | `proposalId` | `string (uuid) \| null` | yes | The proposal the joined hours land in; `null` while none exists yet. | | `projectSeconds` | `integer` | yes | The joining project’s own open seconds in this period, from `fromMonth` on: its entries’ and its allowance months’. | | `projectFixedPriceItemCount` | `integer` | yes | The joining project’s completed fixed-price items in this period that no invoice claims and no proposal holds. An item has no seconds: it is counted here, and its amount is in `amountMinor`. | | `seconds` | `integer` | yes | Seconds the period’s draft would bill: everything still open on its proposal, organization-wide, plus the project’s. For `running`, the project’s alone, up to now. Fixed-price items are no seconds. | | `amountMinor` | `integer` | yes | That draft’s subtotal in minor units, rounded once per line as the invoice would be, fixed-price items included, and provisional until an invoice claims what it bills. | | `missingRateSeconds` | `integer` | yes | Billable seconds among them that no rate prices. | | `createsInvoice` | `boolean` | yes | Whether a join with `createInvoices` drafts this period: it has ended and its draft would bill at least one line. A period whose services no rate prices gets none. | ### ProjectAdditionPreview **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | | | `earliestMonth` | `string` | yes | The earliest month the project can join from. A join fills one gap in the project’s coverage on every rule, from the rule’s first period on: the latest gap that has begun, or the first one while all lie ahead. | | `latestMonth` | `string \| null` | yes | The gap’s last month, inclusive, when coverage follows it; the join ends there. `null` when none does. | | `fromMonth` | `string` | yes | The month this preview starts from. | | `suggestedFromMonth` | `string \| null` | yes | The oldest month between `earliestMonth` and `latestMonth` with open time on the project; `null` when there is none. | | `olderOutstandingSeconds` | `integer` | yes | The project’s open seconds before `earliestMonth`, entries and allowance months alike. This join cannot take them on; they need an invoice of their own. | | `olderOutstandingFixedPriceItemCount` | `integer` | yes | The project’s completed fixed-price items, by the month of their last day, before `earliestMonth`. Like the seconds, this join cannot take them on. | | `periods` | `ProjectAdditionPeriod[]` | yes | | ### ProjectAdditionResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `rule` | `BillingRule` | yes | | | `proposalIds` | `string (uuid)[]` | yes | The ended periods’ proposals that now hold the project’s open hours. | | `invoiceIds` | `string (uuid)[]` | yes | The drafts created, in period order. Empty unless `createInvoices`. | | `skippedProposalIds` | `string (uuid)[]` | yes | Proposals among `proposalIds` that got no draft although `createInvoices` asked for one: nothing on them could be billed (no rate prices it), or another invoice claimed it meanwhile. Their services stay open. | ### ProjectDetail **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `aliases` | `string[]` | yes | Alternative names. They are included in resource search and when filtering task pickers. | | `rules` | `RateRule[]` | yes | The project’s billing rules, least specific first. Empty means nothing on this project has a rate. | | `visibility` | `"public" \| "private"` | yes | Who may see the project. `public` is the whole organization. | | `viewerMayWrite` | `boolean` | yes | | | `memberCount` | `integer` | yes | | | `color` | `string \| null` | yes | | | `icon` | `ResourceIcon` | yes | | | `iconInheritedFrom` | `VisualProvenance` | yes | | | `colorInheritedFrom` | `VisualProvenance` | yes | | | `archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `closedThroughDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `hoursThisMonthSeconds` | `integer` | yes | All tracked seconds this month, billable or not — an entry still running has none yet. | | `unbilled` | `object \| null` | no | Present only with `includeUnbilled=true`; absent otherwise, which is not the same as `null` (nothing outstanding). Computing it reads the whole organization’s open time, so ask for it only when the screen shows the amount. | | `unbilled.seconds` | `integer` | yes | | | `unbilled.amountMinor` | `integer` | yes | | | `unbilled.currency` | `string` | yes | ISO-4217 currency code. | | `unbilled.unratedSeconds` | `integer` | yes | | | `lastActivityAt` | `string (date-time) \| null` | yes | Most recent time entry’s start, across everyone. `null` = never tracked. | | `running` | `boolean` | yes | Somebody has a timer running on this project. | ### ProjectHours **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | | | `projectName` | `string` | yes | | | `customerId` | `string (uuid)` | yes | | | `customerName` | `string` | yes | | | `totalSeconds` | `integer` | yes | | ### ProjectionChange **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `changedAt` | `string (date-time)` | yes | When this document last changed — the stamp of the run that changed it. | | `deleted` | `boolean` | yes | A tombstone. **The sync job must act on this**: delete the id from the index, or entries deleted in Timjami stay in Kibana forever. | | `document` | `ProjectionDocument` | yes | | ### ProjectionDocument **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | `::`. Stable in its start, its month and its uniqueness — not in its end: a moved allocation boundary is a tombstone plus a new id. | | `month` | `string` | yes | The entry's own month in the organization's time zone. It is in the id so a re-dated entry is a tombstone in the old month and a document in the new one. | | `entryId` | `string (uuid)` | yes | | | `fromSecond` | `integer` | yes | Start offset within the entry, in seconds — the coordinate an invoice claim uses, so it survives an admin moving `startedAt`. | | `toSecond` | `integer` | yes | Exclusive end offset. | | `seconds` | `integer` | yes | | | `startedAt` | `string (date-time)` | yes | This stretch's start, not the entry's. | | `endedAt` | `string (date-time)` | yes | This stretch's end, not the entry's. | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `personName` | `string` | yes | | | `taskId` | `string (uuid)` | yes | | | `taskName` | `string` | yes | | | `projectId` | `string (uuid)` | yes | | | `projectName` | `string` | yes | | | `customerId` | `string (uuid)` | yes | | | `customerName` | `string` | yes | | | `billable` | `boolean` | yes | From the **task**, not from the entry — an entry stores no billability of its own (invariant 1). Non-billable time is exported too: the billable-rate metric's denominator needs it. | | `budgetId` | `string (uuid) \| null` | yes | The pot the allocation put these seconds in, or `null` when no budget covers them. | | `budgetName` | `string \| null` | yes | | | `rateMinor` | `integer \| null` | yes | Hourly rate in minor units. `null` means nothing priced these seconds — never `0` (invariant 19). | | `amountMinor` | `integer \| null` | yes | This stretch's value, rounded over its own seconds. Summing `final` money therefore approximates the invoiced total rather than reproducing it — an invoice line rounds once over its whole quantity (invariant 16). | | `currency` | `string` | yes | ISO-4217 currency code. | | `status` | `"provisional" \| "final" \| "written-off"` | yes | `final` is a stretch a **transferred** invoice claims, or one an `external` claim covers — money that has left, through the invoicing tool or through another system. `written-off` is a stretch a live write-off waived, valued at the rate it recorded: money that never arrived and never will, which is why it is neither of the other two. Everything else is `provisional`, a draft's claim included. Not monotonic: withdrawing an invoice or taking a write-off back puts its seconds back to `provisional`. Facet on this field; a dashboard summing these into one number without saying so is the failure invariant 19 exists to prevent. | | `invoiceId` | `string (uuid) \| null` | yes | Which **Timjami** invoice froze this stretch. `null` for everything provisional — and also for a `final` stretch an `external` claim settled and for every `written-off` one, whose document is not an invoice. Key revenue by `status`, not by the presence of this field. | ### ProjectListItem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `aliases` | `string[]` | yes | Alternative names. They are included in resource search and when filtering task pickers. | | `rules` | `RateRule[]` | yes | The project’s billing rules, least specific first. Empty means nothing on this project has a rate. | | `visibility` | `"public" \| "private"` | yes | Who may see the project. `public` is the whole organization. | | `viewerMayWrite` | `boolean` | yes | | | `memberCount` | `integer` | yes | | | `color` | `string \| null` | yes | | | `icon` | `ResourceIcon` | yes | | | `iconInheritedFrom` | `VisualProvenance` | yes | | | `colorInheritedFrom` | `VisualProvenance` | yes | | | `archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `closedThroughDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `hoursThisMonthSeconds` | `integer` | yes | All tracked seconds this month, billable or not — an entry still running has none yet. | | `unbilled` | `object \| null` | no | Present only with `includeUnbilled=true`; absent otherwise, which is not the same as `null` (nothing outstanding). Computing it reads the whole organization’s open time, so ask for it only when the screen shows the amount. | | `unbilled.seconds` | `integer` | yes | | | `unbilled.amountMinor` | `integer` | yes | | | `unbilled.currency` | `string` | yes | ISO-4217 currency code. | | `unbilled.unratedSeconds` | `integer` | yes | | | `lastActivityAt` | `string (date-time) \| null` | yes | Most recent time entry’s start, across everyone. `null` = never tracked. | | `running` | `boolean` | yes | Somebody has a timer running on this project. | | `members` | `object[]` | yes | Who is assigned. Resolve to a name/avatar client-side via the org’s member-avatars endpoint rather than here — this list already fetches that once for the whole page. | | `members.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | ### ProjectMember **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | The assigned person. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### ProjectSetup **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | The customer the project is for. 404 when you cannot see it. | | `project` | `object` | yes | The project. | | `project.name` | `string` | yes | | | `project.aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `project.visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `project.hourlyRateMinor` | `integer` | no | The project’s default hourly rate in minor units, as one rule from the beginning. Not together with `rateTemplateProjectId`. | | `project.rateTemplateProjectId` | `string (uuid)` | no | A project whose rates are copied: only the version in force today, as one version from the beginning. Its default rule is copied; a task rule moves to the new task of the same name (case-insensitively) and is left out without one; a person rule is left out for a former member — somebody whose membership ended or whose account was deleted — and kept for a colleague who never had an account; a budget rule never. Everything left out is listed in `droppedRateRules`. 404 when you cannot see the project. | | `project.tasks` | `object[]` | no | The tasks to create. No two may share a name, compared case-insensitively. | | `project.personIds` | `string (uuid)[]` | no | The people to assign. You are assigned anyway, named here or not. | ### ProjectSetupResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `project` | `Project` | yes | | | `droppedRateRules` | `DroppedRateRule[]` | yes | | ### ProjectWriteOffCut **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `entries` | `WriteOffCut \| unknown \| null` | yes | | | `allowance` | `AllowanceWriteOffCut \| unknown \| null` | yes | | ### ProposalInvoicePreview **Fields** | Field | Type | Required | Description | |---|---|---|---| | `proposal` | `BillingProposalDetail` | yes | | | `draft` | `InvoiceDraft` | yes | | | `period` | `ProposalPeriod` | yes | | ### ProposalPeriod **Fields** | Field | Type | Required | Description | |---|---|---|---| | `lastDay` | `string` | yes | The last day this invoice reads, inclusive — the day a close would have to reach. | | `projects` | `object[]` | yes | The projects the rule covered over the period (and any an open service names beyond them), with how far each is closed. Asked, never acted on: nothing closes a period as a side effect. | | `projects.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.projectName` | `string` | yes | | | `projects.closedThroughDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `notBillable` | `InvoiceExclusion \| unknown \| null` | yes | The not-billable time booked on those projects within the proposal’s own period, per task — `null` when there is none. The draft’s own `not-billable` group reaches back over every month before it. | | `uncoveredProjects` | `object[]` | yes | Other projects of the customer with open billable hours, or completed fixed-price items, in the period that no rule covers. Empty for a catch-up proposal. | | `uncoveredProjects.projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `uncoveredProjects.projectName` | `string` | yes | | | `uncoveredProjects.openSeconds` | `integer` | yes | | | `uncoveredProjects.openFixedPriceItemCount` | `integer` | yes | Completed fixed-price items on the project, by the month of their last day within the period, that no invoice claims and no proposal holds. They have no seconds. | | `uncoveredProjects.joinable` | `boolean` | yes | Whether this proposal’s rule can take the project over a month of the period — `POST /billing-rules/{id}/project-additions`. | | `unallocatedSeconds` | `integer` | yes | Billable seconds in the period on projects the rule covers that no proposal holds yet, so this invoice cannot bill them. The invoice routes reconcile first, so this is what that pass could not reach. Zero for a catch-up proposal. | ### PutAssistantTourProgress Type: `object | object` ### PutIcon **Fields** | Field | Type | Required | Description | |---|---|---|---| | `contentType` | `"image/webp" \| "image/png"` | yes | Must match what the bytes actually are; a mismatch is rejected. | | `data` | `string` | yes | Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. | ### PutRateRules **Fields** | Field | Type | Required | Description | |---|---|---|---| | `rules` | `WriteRateRule[]` | yes | | ### RateExposureMonth **Fields** | Field | Type | Required | Description | |---|---|---|---| | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `trackedSeconds` | `integer` | yes | | | `invoicedSeconds` | `integer` | yes | Of those, the seconds a live invoice claims. What makes a change refusable. | ### RateRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | | | `taskId` | `string (uuid) \| null` | yes | The task this rule is for, or null for any task. | | `personId` | `string (uuid) \| null` | yes | The person this rule is for, or null for anybody. | | `budgetId` | `string (uuid) \| null` | yes | The budget whose hours this rule prices, or null for any budget — hours no budget covers included. An hour is priced by the pot the allocation put it in, so a rule naming one prices exactly those seconds. | | `validFromMonth` | `string \| null` | yes | The month this rule’s version starts in, or null for "from the beginning". The next version replaces it whole. | | `hourlyRateMinor` | `integer` | yes | Hourly rate in minor units of the organization currency. Never null: no rate is the absence of a rule. | ### RateRules **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `RateRule[]` | yes | | | `exposureByMonth` | `RateExposureMonth[]` | yes | | | `pricingBudgets` | `PricingBudget[]` | yes | | | `namedBudgets` | `RetiredPricingBudget[]` | yes | | ### RecentTask **Fields** | Field | Type | Required | Description | |---|---|---|---| | `task` | `object` | yes | | | `task.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `task.name` | `string` | yes | | | `task.displayName` | `string` | yes | Server-selected task label, composed from displayNameParts in the same priority order. | | `task.displayNameParts` | `string[]` | yes | One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. | | `task.isBillable` | `boolean` | yes | | | `task.icon` | `ResourceIcon` | yes | | | `project` | `object` | yes | | | `project.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `project.name` | `string` | yes | | | `project.color` | `string \| null` | yes | | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `isBillable` | `boolean` | yes | | | `billableRateMinor` | `integer \| null` | yes | Hourly rate in minor units of the organization currency. Always hourly, never a day rate. | | `rateAxes` | `("taskId" \| "personId" \| "budgetId")[] \| null` | yes | Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer. | | `currency` | `string` | yes | ISO-4217 currency code. | | `lastBookedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `entryCount` | `integer` | yes | | ### RecordedDocumentInput **Fields** | Field | Type | Required | Description | |---|---|---|---| | `number` | `string \| null` | yes | | | `documentDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `dueDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `url` | `string \| null` | yes | | | `sentDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | | `paidDay` | `string \| null` | yes | A calendar day, `YYYY-MM-DD`. | ### RecordedOutstanding **Fields** | Field | Type | Required | Description | |---|---|---|---| | `openMinor` | `integer` | yes | | | `openCount` | `integer` | yes | | | `overdueMinor` | `integer` | yes | | | `overdueCount` | `integer` | yes | | | `withheldCount` | `integer` | yes | | ### RedeemInviteCodeRequest **Fields** | Field | Type | Required | Description | |---|---|---|---| | `code` | `string` | yes | An invite code, in any casing or spacing — normalized server-side. | ### RefinePlanningAllocation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `source` | `object` | yes | The existing target whose hours are reduced. | | `source.projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `source.customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `source.budgetId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `destination` | `object` | yes | The target receiving the hours. Must belong to the same customer; a project and budget must be linked. | | `destination.projectId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `destination.customerId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `destination.budgetId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `moveSeconds` | `integer` | yes | Whole seconds to move, at most the source hours and at most 604800 seconds at the destination in total. | | `expectedSourceSeconds` | `integer` | yes | The source plannedSeconds from the last overview read. | | `sourceAllocationId` | `string (uuid)` | yes | The id of the source allocation from the last overview read. | | `expectedSourceUpdatedAt` | `string (date-time)` | yes | The source allocation updatedAt from the last overview read; a changed revision is refused. | ### ReleaseExternalSettlement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `selector` | `ExternalClaimSelector` | yes | | ### ReportHint **Fields** | Field | Type | Required | Description | |---|---|---|---| | `source` | `string` | yes | Identifier of the collector that reported this, e.g. `claude-code` or `browser`. Lowercase slug, chosen by the collector. | | `externalId` | `string \| null` | no | Stable id from the collector, e.g. a session id. | | `clientHintId` | `string \| null` | no | The reporting app’s own idempotency key for this report. | | `sessionKey` | `string \| null` | no | Groups this report with the same session’s other reports; every report is its own observation. | | `subject` | `string` | yes | Short label: what the collector thinks the person was working on. | | `path` | `HintPath` | no | | | `details` | `string \| null` | no | Optional longer context — a URL, the files touched, the prompt. | | `startedAt` | `string (date-time)` | no | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | no | ISO-8601 timestamp with offset. | | `signal` | `"presence" \| "absence"` | no | | ### ResourceIcon **Fields** | Field | Type | Required | Description | |---|---|---|---| | `url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `contentType` | `"image/webp" \| "image/png"` | yes | | | `width` | `integer` | yes | | | `height` | `integer` | yes | | | `byteSize` | `integer` | yes | | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `checksum` | `string` | yes | | ### ResumeBillingRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `restartFromPeriod` | `string \| null` | no | A chosen period to resume from, becoming the rule’s new generation anchor. Omitted resumes from where it left off. | ### RetaskTimeEntries **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entryIds` | `string (uuid)[]` | yes | | | `taskId` | `string (uuid)` | yes | The task every named entry moves onto. | | `editReason` | `string` | no | Why hours that are frozen are being changed anyway — a live claim, a closed tracking period, or a day further back than this organization records time for. Applies to every entry of the batch that needs one. An entry that needs it and does not get it is **failed** with a 422 problem carrying `reasonRequired: true`, and the rest of the batch still runs. Ignored for an entry nothing is holding. It is kept with the revision each write appends, readable in that entry’s history. | ### RetiredPricingBudget **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `validFromMonth` | `string` | yes | A calendar month, `YYYY-MM`. | | `validUntilMonth` | `string \| null` | yes | A calendar month, `YYYY-MM`. | | `archived` | `boolean` | yes | Whether the budget was archived. `false` means it still exists but its chain no longer reaches this project — a different sentence for a screen to say. | ### RevisionExternalReference **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | yes | | | `key` | `string` | yes | | | `url` | `string \| null` | yes | | | `title` | `string \| null` | yes | | ### SearchResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customers` | `object[]` | yes | | | `customers.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customers.name` | `string` | yes | | | `customers.icon` | `ResourceIcon` | yes | | | `customers.color` | `string` | yes | | | `customers.archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `projects` | `object[]` | yes | | | `projects.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projects.name` | `string` | yes | | | `projects.icon` | `ResourceIcon` | yes | | | `projects.color` | `string \| null` | yes | | | `projects.archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `projects.customer` | `object` | yes | | | `tasks` | `object[]` | yes | | | `tasks.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `tasks.name` | `string` | yes | | | `tasks.icon` | `ResourceIcon` | yes | | | `tasks.displayName` | `string` | yes | Server-selected task label, composed from displayNameParts in the same priority order. | | `tasks.displayNameParts` | `string[]` | yes | One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. | | `tasks.isBillable` | `boolean` | yes | | | `tasks.archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `tasks.project` | `object` | yes | | | `tasks.customer` | `object` | yes | | ### SetBudgetAdjustment **Fields** | Field | Type | Required | Description | |---|---|---|---| | `adjustmentSeconds` | `integer` | yes | A signed delta in whole seconds, added to the budget’s agreed capacity for this month alone. Use a negative value when earlier work consumed capacity; this is not a replacement total and zero must be removed instead. | | `note` | `string \| null` | no | Optional explanation for why this month differs. Free text; `null` clears the note. | ### SetEasybillCustomerMapping **Fields** | Field | Type | Required | Description | |---|---|---|---| | `easybillCustomerId` | `string` | yes | | ### SetEasybillExpense **Fields** | Field | Type | Required | Description | |---|---|---|---| | `easybillExpensePositionId` | `string \| null` | yes | The Easybill article's id. | ### SetEasybillService **Fields** | Field | Type | Required | Description | |---|---|---|---| | `easybillServicePositionId` | `string` | yes | The Easybill article's id. | ### SetPlanningAllocation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Who this plans for. Must be a person of the active organization who still holds a membership. | | `projectId` | `string (uuid) \| null` | no | The project target. Keep this field for compatibility with existing clients. | | `customerId` | `string (uuid) \| null` | no | The direct customer target. Exactly one target id is required. | | `budgetId` | `string (uuid) \| null` | no | The direct budget target. Exactly one target id is required. | | `weekStartDay` | `string` | yes | A Monday, `YYYY-MM-DD` — an **ISO week**, regardless of the organization’s own `weekStartsOn` display setting. A calendar week has one number and one Monday, and a plan is shared between people who may prefer different display settings. | | `plannedSeconds` | `integer` | yes | Whole seconds planned for this week. `0` removes the row — the only way an allocation is deleted. | | `note` | `string \| null` | no | | ### SetPlanningContext **Fields** | Field | Type | Required | Description | |---|---|---|---| | `targetType` | `"customer" \| "person" \| "project" \| "budget"` | yes | | | `targetId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `notes` | `string` | yes | Free-form planning context. Empty or whitespace-only text clears it. Treated as data, never executable instructions. | ### SetSevdeskContactPerson **Fields** | Field | Type | Required | Description | |---|---|---|---| | `contactPersonId` | `string` | yes | sevdesk's own id. | ### SetSevdeskCustomerMapping **Fields** | Field | Type | Required | Description | |---|---|---|---| | `sevdeskContactId` | `string` | yes | sevdesk's own id. | ### SetSevdeskExpensePart **Fields** | Field | Type | Required | Description | |---|---|---|---| | `expensePartId` | `string \| null` | yes | sevdesk's own id. | ### SetSevdeskServicePart **Fields** | Field | Type | Required | Description | |---|---|---|---| | `servicePartId` | `string` | yes | sevdesk's own id. | ### SetSevdeskTaxRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taxRuleId` | `1 \| 11` | yes | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | ### SetTimeAllowanceAdjustment **Fields** | Field | Type | Required | Description | |---|---|---|---| | `deltaSeconds` | `integer` | yes | A signed delta in whole seconds on this month alone — what somebody decided, never a recomputed total. Negative for a month that was shorter than agreed. Zero is refused: no adjustment is the absence of a row, so use `DELETE`. | | `note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | ### SevdeskAccount **Fields** | Field | Type | Required | Description | |---|---|---|---| | `suggestedTaxRuleId` | `1 \| 11` | yes | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | | `taxRuleWarning` | `string \| null` | yes | | | `smallSettlement` | `boolean` | yes | | ### SevdeskConnection **Fields** | Field | Type | Required | Description | |---|---|---|---| | `configured` | `boolean` | yes | | | `servicePartId` | `string \| null` | yes | | | `servicePartLabel` | `string \| null` | yes | | | `expensePartId` | `string \| null` | yes | | | `expensePartLabel` | `string \| null` | yes | | | `contactPersonId` | `string \| null` | yes | | | `contactPersonLabel` | `string \| null` | yes | | | `taxRuleId` | `1 \| 11 \| unknown \| null` | yes | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | | `connectedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | ### SevdeskConnectionResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `configured` | `boolean` | yes | | | `servicePartId` | `string \| null` | yes | | | `servicePartLabel` | `string \| null` | yes | | | `expensePartId` | `string \| null` | yes | | | `expensePartLabel` | `string \| null` | yes | | | `contactPersonId` | `string \| null` | yes | | | `contactPersonLabel` | `string \| null` | yes | | | `taxRuleId` | `1 \| 11 \| unknown \| null` | yes | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | | `connectedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `suggestedTaxRuleId` | `1 \| 11` | yes | The sevdesk `taxRule` every export sends: `1` taxable in Germany, `11` Kleinunternehmer (§19). | | `taxRuleWarning` | `string \| null` | yes | | ### SevdeskContactList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.name` | `string \| null` | yes | | | `data.customerNumber` | `string \| null` | yes | | | `data.categoryId` | `string \| null` | yes | sevdesk’s category of the contact: 3 is a customer, 2 a supplier; others are not captured. | | `page` | `integer` | yes | | | `pages` | `integer` | yes | | ### SevdeskContactPersonList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.name` | `string \| null` | yes | | | `data.role` | `string \| null` | yes | | ### SevdeskCustomerMapping **Fields** | Field | Type | Required | Description | |---|---|---|---| | `sevdeskContactId` | `string` | yes | | | `sevdeskContactName` | `string \| null` | yes | | ### SevdeskCustomerMappingList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `SevdeskCustomerMapping & object[]` | yes | | ### SevdeskPartList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `object[]` | yes | | | `data.id` | `string` | yes | | | `data.partNumber` | `string \| null` | yes | | | `data.name` | `string \| null` | yes | | | `data.vatPercent` | `number \| null` | yes | | | `mayHaveMore` | `boolean` | yes | | ### SlackAuthorizeUrl **Fields** | Field | Type | Required | Description | |---|---|---|---| | `url` | `string (uri)` | yes | | ### SlackConnection **Fields** | Field | Type | Required | Description | |---|---|---|---| | `configured` | `boolean` | yes | | | `workspace` | `object \| null` | yes | | | `workspace.teamId` | `string` | yes | | | `workspace.teamName` | `string` | yes | | | `workspace.connectedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `identity` | `object \| null` | yes | | | `identity.slackUserId` | `string` | yes | | | `identity.includeMessageText` | `boolean` | yes | | | `identity.statusEnabled` | `boolean` | yes | | | `identity.statusTemplate` | `string \| null` | yes | | | `identity.statusEmoji` | `string \| null` | yes | | | `identity.presenceEnabled` | `boolean` | yes | | | `identity.huddleEnabled` | `boolean` | yes | | ### SplitTimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `parts` | `object[]` | yes | The parts in clock order. They must fill the entry exactly: the first starts where it starts, each next one where the one before ends, and the last ends where it ends. | | `parts.taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `parts.startedAt` | `string (date-time)` | yes | Where this part starts. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `parts.endedAt` | `string (date-time)` | yes | Where this part ends. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `editReason` | `string` | no | Why hours are being split inside a **closed** tracking period anyway. **Required** — 422 with `reasonRequired: true` — when the request gets through only because the caller holds `time.editBilled`; kept with every revision this write appends. | ### SplitTimeEntryResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `TimeEntry[]` | yes | | ### StartTimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `notes` | `string \| null` | no | | | `startedAt` | `string (date-time)` | no | When the timer started. Defaults to now — useful for "I forgot to start the timer 10 minutes ago". Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `stopRunning` | `boolean` | no | | | `editReason` | `string` | no | Why hours are being booked into a **closed** tracking period anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the day this entry starts in is at or below its project’s `closedThroughDay` and the request gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other booking. It is kept with the revision this write appends, readable in the entry’s history. | ### StopTimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `endedAt` | `string (date-time)` | no | When to stop. Defaults to now. Stopping inside the second the timer started in leaves an entry of no length, which is an ordinary row. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | ### SupersedeBudget **Fields** | Field | Type | Required | Description | |---|---|---|---| | `replaceIds` | `string (uuid)[]` | yes | The budgets this one replaces. Each is ended the month before the successor starts, in the same transaction. Several IDs allow a fan-in replacement. All must belong to the successor’s customer. | | `successor` | `object` | yes | The new budget that replaces every `replaceIds` budget in the same transaction. | | `successor.customerId` | `string (uuid)` | yes | The customer that owns this budget. Every `projectIds` entry and referenced overflow budget must belong to this customer. | | `successor.name` | `string` | yes | A human-readable name for the agreement; it remains stable when the project composition changes. | | `successor.validFromMonth` | `string` | yes | The first calendar month covered by the budget, inclusive. | | `successor.validUntilMonth` | `string \| null` | yes | The last calendar month covered by the budget, inclusive. `null` keeps it open-ended. | | `successor.budgetedSeconds` | `integer` | yes | The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`. | | `successor.kind` | `"monthly" \| "total"` | no | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `successor.overflowIntoId` | `string (uuid) \| null` | no | Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused. | | `successor.overflowFromId` | `string (uuid) \| null` | no | Optionally name one live budget of the same customer whose overflow should point to this new budget in the same transaction. This creates a supplementary pot safely; it is refused if that source already overflows elsewhere. | | `successor.projectIds` | `string (uuid)[]` | yes | One or more project IDs covered by the budget. Every project must belong to `customerId`. | ### SupersedeTimeAllowance **Fields** | Field | Type | Required | Description | |---|---|---|---| | `replaceIds` | `string (uuid)[]` | yes | The agreements this one replaces. Each is ended the month **before** the successor starts, in the same transaction — „ab Oktober sind es zwei Stunden" is one thing somebody agreed. All must be for the successor’s own person and task. | | `successor` | `object` | yes | The agreement that takes over, written in the same transaction. | | `successor.personId` | `string (uuid)` | yes | Who the allowance is for. Must be a person of the active organization who still holds a membership. | | `successor.taskId` | `string (uuid)` | yes | The task the months are billed on. Must be **billable** (422 otherwise — an allowance on work nothing prices is not a smaller number, it is a meaningless one) and not archived (409). | | `successor.secondsPerMonth` | `integer` | yes | The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. | | `successor.validFromMonth` | `string` | yes | The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on. | | `successor.validUntilMonth` | `string \| null` | yes | The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October. | | `successor.note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | ### Task **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | | `displayName` | `string` | yes | Server-selected task label, composed from displayNameParts in the same priority order. | | `displayNameParts` | `string[]` | yes | One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. | | `billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | yes | How this task’s hours are billed. `hourly` is priced by the hour; `productive` is value-creating work that is never invoiced; `internal` is neither; `fixed_price` is billed through the task’s fixed-price agreement, not by the hour. Prefer this over `isBillable` and `isProductive`, which are derived from it. | | `fixedPriceAgreementId` | `string (uuid) \| null` | no | The fixed-price agreement this task’s hours are spent on. Its customer must be the task’s customer. `fixed_price` requires one, `hourly` and `productive` forbid one, `internal` may carry one. | | `fixedPriceAgreement` | `TaskFixedPriceAgreement` | no | | | `isBillable` **(deprecated)** | `boolean` | yes | Deprecated, use `billingMode`. Derived: `billingMode === "hourly"`, so it is false for every other mode, including work that is billed but not by the hour. | | `isProductive` **(deprecated)** | `boolean` | yes | Deprecated, use `billingMode`. Derived: `billingMode === "productive"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `isCatchAll` | `boolean` | yes | Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `color` | `string` | yes | | | `icon` | `ResourceIcon` | yes | | | `iconInheritedFrom` | `VisualProvenance` | yes | | | `colorInheritedFrom` | `VisualProvenance` | yes | | | `archivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### TaskFixedPriceAgreement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | The fixed-price agreement this task’s hours are spent on. Its customer must be the task’s customer. `fixed_price` requires one, `hourly` and `productive` forbid one, `internal` may carry one. | | `name` | `string` | yes | | ### TaskNameSuggestion **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | yes | The spelling most of the organization’s tasks with this name use. | | `billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | yes | The billing mode most of these tasks carry, as on a task. | | `isBillable` **(deprecated)** | `boolean` | yes | Deprecated, use `billingMode`. Derived: `billingMode === "hourly"`, so it is false for every other mode, including work that is billed but not by the hour. | | `isProductive` **(deprecated)** | `boolean` | yes | Deprecated, use `billingMode`. Derived: `billingMode === "productive"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `projectCount` | `integer` | yes | How many projects carry the name — what the suggestions are ranked by. | ### TicketDayReportRow **Fields** | Field | Type | Required | Description | |---|---|---|---| | `day` | `string` | yes | The calendar day the entry started on, read in the organization’s time zone — which the response repeats in `timeZone`, so a client labels the column with the zone the buckets were cut in. | | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | yes | | | `key` | `string` | yes | | | `url` | `string \| null` | yes | | | `title` | `string \| null` | yes | | | `totalSeconds` | `integer` | yes | | | `carriedMinutes` | `integer \| null` | yes | | | `entryCount` | `integer` | yes | | | `personIds` | `string (uuid)[]` | yes | | ### TicketReportRow **Fields** | Field | Type | Required | Description | |---|---|---|---| | `provider` | `"linear" \| "jira" \| "azure_devops" \| "github" \| "generic"` | yes | | | `key` | `string` | yes | | | `url` | `string \| null` | yes | | | `title` | `string \| null` | yes | | | `totalSeconds` | `integer` | yes | | | `entryCount` | `integer` | yes | | | `personIds` | `string (uuid)[]` | yes | | | `firstStartedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lastStartedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### TimeAllowance **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | The person the allowance is **for** — usually not the person who agreed it. | | `personName` | `string` | yes | | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `taskName` | `string` | yes | | | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectName` | `string` | yes | | | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `secondsPerMonth` | `integer` | yes | The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. | | `validFromMonth` | `string` | yes | The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on. | | `validUntilMonth` | `string \| null` | yes | The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October. | | `note` | `string \| null` | yes | | | `projectArchivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `customerArchivedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `adjustments` | `object[]` | yes | Every month of this agreement somebody corrected, oldest first. | | `adjustments.month` | `string` | yes | A calendar month, `YYYY-MM`. | | `adjustments.deltaSeconds` | `integer` | yes | A signed delta in whole seconds on this month alone, never a replacement total. Negative for a month that was shorter than agreed. Never zero — no adjustment is a missing row. | | `adjustments.note` | `string \| null` | yes | | | `billedMonths` | `string[]` | yes | The months a **live** invoice already bills, oldest first. A month in here is not freely correctable any more: the document froze what it was worth, so the way back is to withdraw that invoice — which releases the month rather than deleting the record. | | `createdByPersonId` | `string (uuid) \| null` | yes | Who agreed it. Null once that person was deleted — the row outlives them. | | `updatedByPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### TimeAllowanceAdjustment **Fields** | Field | Type | Required | Description | |---|---|---|---| | `allowanceId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `month` | `string` | yes | A calendar month, `YYYY-MM`. | | `deltaSeconds` | `integer` | yes | A signed delta in whole seconds on this month alone — what somebody decided, never a recomputed total. Negative for a month that was shorter than agreed. Zero is refused: no adjustment is the absence of a row, so use `DELETE`. | | `note` | `string \| null` | yes | | ### TimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `userId` **(deprecated)** | `string \| null` | yes | Deprecated: the better-auth account behind `personId`. Present so clients built before the rename keep parsing; it falls with the `?userId=` query alias. `null` where the person has no account. | | `notes` | `string \| null` | yes | | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `durationSeconds` | `integer` | yes | | | `isRunning` | `boolean` | yes | | | `isBillable` | `boolean` | yes | | | `billableRateMinor` | `integer \| null` | yes | Hourly rate in minor units of the organization currency. Always hourly, never a day rate. | | `currency` | `string` | yes | ISO-4217 currency code. | | `rateAxes` | `("taskId" \| "personId" \| "budgetId")[] \| null` | yes | Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer. | | `billableAmountMinor` | `integer \| null` | yes | | | `rateSlices` | `object[]` | yes | Which pot holds which of the entry’s seconds, and what each is worth, **in the order the budgets are filled** — contiguous, so their seconds sum to `durationSeconds` and each slice is a stretch of the entry even though no offsets are given. A slice may itself be unpriced, which is how an entry ends up with an amount over only part of its hours — count „billable time with no rate" from here, never from `billableAmountMinor === null`. The **number** of slices is not a stable property of an entry: an entry no budget rule prices is one slice wherever it appears, and the same entry is split only in a response where some rule does name a budget. The amounts are the same either way, so read the slices, not their count. | | `rateSlices.budgetId` | `string (uuid) \| null` | yes | The budget that **priced** these seconds, or null when no budget priced them. Not a statement about consumption: when no rule of the project names a budget nothing is allocated at all, so every slice answers null while `/budgets/{id}/consumption` goes on counting the same hours against a pot. | | `rateSlices.seconds` | `integer` | yes | | | `rateSlices.rateMinor` | `integer \| null` | yes | Hourly rate in minor units of the organization currency. Always hourly, never a day rate. | | `rateSlices.amountMinor` | `integer \| null` | yes | | | `rateSlices.rateAxes` | `("taskId" \| "personId" \| "budgetId")[] \| null` | yes | Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### TimeEntryList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `TimeEntryWithContext[]` | yes | | | `nextCursor` | `string \| null` | yes | Cursor for the next page, or null when the list is exhausted. | ### TimeEntryLogEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `action` | `"created" \| "updated" \| "deleted" \| "reference.attached" \| "reference.detached"` | yes | | | `recordedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `mergedIntoId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `actorPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `actorUserId` **(deprecated)** | `string \| null` | yes | Deprecated: the better-auth account behind `actorPersonId`. It falls with the `?userId=` query alias. | | `actorName` | `string \| null` | yes | | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `userId` **(deprecated)** | `string \| null` | yes | Deprecated: the better-auth account behind `personId`. It falls with the `?userId=` query alias. `null` where the person has no account. | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `notes` | `string \| null` | yes | | | `demoDataSetId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `taskName` | `string \| null` | yes | | | `projectName` | `string \| null` | yes | | | `customerName` | `string \| null` | yes | | | `reason` | `string \| null` | yes | | | `externalReference` | `RevisionExternalReference` | yes | | ### TimeEntryLogHead **Fields** | Field | Type | Required | Description | |---|---|---|---| | `timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `notes` | `string \| null` | yes | | | `taskName` | `string \| null` | yes | | | `projectName` | `string \| null` | yes | | | `customerName` | `string \| null` | yes | | ### TimeEntryRevision **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `timeEntryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `action` | `"created" \| "updated" \| "deleted" \| "reference.attached" \| "reference.detached"` | yes | | | `position` | `integer` | yes | | | `recordedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `mergedIntoId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `actorPersonId` | `string (uuid) \| null` | yes | Person identifier (UUID v4) — a person, not a login. | | `actorName` | `string \| null` | yes | | | `actorUserId` **(deprecated)** | `string \| null` | yes | Deprecated: the better-auth account behind `actorPersonId`. Present so clients built before the rename keep reading this log; it falls with the `?userId=` query alias. | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `userId` **(deprecated)** | `string \| null` | yes | Deprecated: the better-auth account behind `personId`. Present so clients built before the rename keep reading this log; it falls with the `?userId=` query alias. `null` where the person has no account. | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `notes` | `string \| null` | yes | | | `demoDataSetId` | `string (uuid) \| null` | yes | Resource identifier (UUID v4). | | `taskName` | `string \| null` | yes | | | `projectName` | `string \| null` | yes | | | `customerName` | `string \| null` | yes | | | `reason` | `string \| null` | yes | | | `externalReference` | `RevisionExternalReference` | yes | | ### TimeEntryWithContext **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `userId` **(deprecated)** | `string \| null` | yes | Deprecated: the better-auth account behind `personId`. Present so clients built before the rename keep parsing; it falls with the `?userId=` query alias. `null` where the person has no account. | | `notes` | `string \| null` | yes | | | `startedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `endedAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `durationSeconds` | `integer` | yes | | | `isRunning` | `boolean` | yes | | | `isBillable` | `boolean` | yes | | | `billableRateMinor` | `integer \| null` | yes | Hourly rate in minor units of the organization currency. Always hourly, never a day rate. | | `currency` | `string` | yes | ISO-4217 currency code. | | `rateAxes` | `("taskId" \| "personId" \| "budgetId")[] \| null` | yes | Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer. | | `billableAmountMinor` | `integer \| null` | yes | | | `rateSlices` | `object[]` | yes | Which pot holds which of the entry’s seconds, and what each is worth, **in the order the budgets are filled** — contiguous, so their seconds sum to `durationSeconds` and each slice is a stretch of the entry even though no offsets are given. A slice may itself be unpriced, which is how an entry ends up with an amount over only part of its hours — count „billable time with no rate" from here, never from `billableAmountMinor === null`. The **number** of slices is not a stable property of an entry: an entry no budget rule prices is one slice wherever it appears, and the same entry is split only in a response where some rule does name a budget. The amounts are the same either way, so read the slices, not their count. | | `rateSlices.budgetId` | `string (uuid) \| null` | yes | The budget that **priced** these seconds, or null when no budget priced them. Not a statement about consumption: when no rule of the project names a budget nothing is allocated at all, so every slice answers null while `/budgets/{id}/consumption` goes on counting the same hours against a pot. | | `rateSlices.seconds` | `integer` | yes | | | `rateSlices.rateMinor` | `integer \| null` | yes | Hourly rate in minor units of the organization currency. Always hourly, never a day rate. | | `rateSlices.amountMinor` | `integer \| null` | yes | | | `rateSlices.rateAxes` | `("taskId" \| "personId" \| "budgetId")[] \| null` | yes | Which axes the rule that decided this rate constrains. `[]` is the project’s default rule; `null` means nothing priced these hours — the task is not billable, no rule of that month matched, or the entry’s slices were priced differently and there is no single answer. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `task` | `object` | yes | | | `task.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `task.name` | `string` | yes | | | `task.displayName` | `string` | yes | Server-selected task label, composed from displayNameParts in the same priority order. | | `task.displayNameParts` | `string[]` | yes | One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. | | `task.isBillable` | `boolean` | yes | | | `project` | `object` | yes | | | `project.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `project.name` | `string` | yes | | | `project.color` | `string \| null` | yes | | | `project.icon` | `ResourceIcon` | yes | | | `project.iconInheritedFrom` | `VisualProvenance` | yes | | | `project.colorInheritedFrom` | `VisualProvenance` | yes | | | `customer` | `object` | yes | | | `customer.id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customer.name` | `string` | yes | | | `customer.color` | `string` | yes | | | `customer.icon` | `ResourceIcon` | yes | | | `updatedAtPrecise` | `string` | yes | | | `harvestExport` | `object \| null` | yes | | | `harvestExport.harvestTimeEntryId` | `string` | yes | | | `harvestExport.syncedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `harvestExport.origin` | `"export" \| "import"` | yes | Which side ran the timer: `import` means Harvest did, `export` means this entry was tracked here and written to Harvest. It decides whether Harvest can hold sub-minute time this entry does not — for an exported entry Harvest's `hours` is a re-derivation of the `HH:mm` we sent it, and since a minute is 0.0166… h that re-derivation disagrees with our own minutes by up to 12 seconds. Written once at pairing time and never changed. See ADR-2026-08-20-11ZJ9. | | `settlement` | `object \| null` | yes | | | `settlement.settledSeconds` | `integer` | yes | How many of this entry’s seconds a live claim holds, clamped to the entry as it stands **now**. Less than `durationSeconds` means part of it is still open — an entry extended after it was billed owes its tail to „Abzurechnen" (invariant 17) — and it can be less than the claims add up to, when somebody shortened the entry after it was billed. It is never more. | | `settlement.claims` | `object[]` | yes | In fill order within the entry, like `rateSlices`. Never empty — an entry nothing settles carries `settlement: null` instead. | | `policy` | `EntryWritePolicy` | yes | | | `externalReferences` | `ExternalReference[]` | yes | | ### TimeProgress **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | | | `total` | `TimeRangeProgress` | yes | | | `throughToday` | `TimeRangeProgress & unknown` | yes | | | `perDay` | `DayProgress[]` | yes | Every day in the range, oldest first, including days after today. | ### TimeRangeProgress **Fields** | Field | Type | Required | Description | |---|---|---|---| | `trackedSeconds` | `integer` | yes | Finished, tracked seconds in the range. | | `billableSeconds` | `integer` | yes | Of trackedSeconds, how much was recorded on a billable task. | | `nonBillableSeconds` | `integer` | yes | | | `unratedSeconds` | `integer` | yes | Billable seconds no rule priced — reported, never billed at zero (invariant 19). | | `billableAmountMinor` | `integer` | yes | The sum of every priced slice. Zero, not omitted, when nothing was priced — unratedSeconds says whether that means nothing billable happened or nothing priced what did. | | `billableRateMinor` | `integer \| null` | yes | The one rate every priced slice agreed on, or null — which covers both "nothing priced" and "gemischt", exactly like a single entry's own billableRateMinor. | | `entryCount` | `integer` | yes | | | `targetSeconds` | `integer` | yes | What the range was expected to hold: the person's working time model (or the organization's hoursPerDay × Mo–Fr fallback), less absences. | | `targetDerived` | `boolean` | yes | True when any day's target came from a monthly working time model's pro-rata division rather than a stated daily figure. | | `runningIncluded` | `boolean` | yes | True when `includeRunning` was asked for and the caller’s own running entry falls inside this range — so a client can mark the figure as still growing. | ### TimerSnapshot **Fields** | Field | Type | Required | Description | |---|---|---|---| | `running` | `TimeEntryWithContext \| unknown \| null` | yes | | | `recentTasks` | `RecentTask[]` | yes | | | `completedTodaySeconds` | `integer` | yes | | | `completedBillableTodaySeconds` | `integer` | yes | | | `dailyTargetSeconds` | `integer` | yes | | | `capturedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### TimerStart **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | yes | Resource identifier (UUID v4). | ### TimerStopResult **Fields** | Field | Type | Required | Description | |---|---|---|---| | `running` | `TimeEntryWithContext \| unknown \| null` | yes | | | `recentTasks` | `RecentTask[]` | yes | | | `completedTodaySeconds` | `integer` | yes | | | `completedBillableTodaySeconds` | `integer` | yes | | | `dailyTargetSeconds` | `integer` | yes | | | `capturedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `stopped` | `TimeEntryWithContext \| unknown \| null` | yes | The entry this request stopped, with its final duration, or `null` when nothing was running. | ### TransferInvoice **Fields** | Field | Type | Required | Description | |---|---|---|---| | `recordedDocument` | `RecordedDocumentInput` | no | | ### UnbilledCustomer **Fields** | Field | Type | Required | Description | |---|---|---|---| | `customerId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `customerName` | `string` | yes | | | `icon` | `object \| null` | yes | | | `icon.url` | `string (uri)` | yes | Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. | | `icon.contentType` | `"image/webp" \| "image/png"` | yes | | | `icon.width` | `integer` | yes | | | `icon.height` | `integer` | yes | | | `icon.byteSize` | `integer` | yes | | | `icon.updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `icon.checksum` | `string` | yes | | | `archived` | `boolean` | yes | | | `seconds` | `integer` | yes | Billable seconds with a resolvable rate, no live invoice claim over them — what `amountMinor` is built from. | | `amountMinor` | `integer` | yes | Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view. | | `currency` | `string` | yes | ISO-4217 currency code. | | `unratedSeconds` | `integer` | yes | Billable hours with no resolvable rate. Reported, never billed at zero — and counted per budget slice, so an entry half of which a budget rule prices contributes only its unpriced half. A monthly time allowance („Pauschale") counts here whole: a month is billed whole or not at all, so one unpriced share makes the month unbillable entire. | | `allowanceSeconds` | `integer` | yes | How many of `seconds` are monthly time allowances („Pauschale") rather than tracked time. Part of `seconds`, not beside it — it is open, billable time — and named because these are hours no time entry backs, so the entries behind this row add up to less than it says. | | `fixedPriceItemCount` | `integer` | yes | Fixed-price items („Posten") on no live invoice, in the organization’s currency, upcoming ones included. An item is an amount billed whole, so it has a count and an amount and no seconds; a released claim (a withdrawn invoice) does not hold it. | | `fixedPriceAmountMinor` | `integer` | yes | The sum of those items’ own amounts, each rounded once when it was written. Stands **beside** `amountMinor` and is never part of it — that figure is rounded per (project, rate) from seconds — so a client states the two together or says which it shows (invariant 19). | | `upcomingFixedPriceItemCount` | `integer` | yes | How many of `fixedPriceItemCount` are not completed yet: the item’s last service day lies after today in the organization’s zone. Part of it, not beside it. | | `upcomingFixedPriceAmountMinor` | `integer` | yes | The amount of those upcoming items. Part of `fixedPriceAmountMinor`, named so a screen can mark it as upcoming instead of owed. | | `otherCurrencyFixedPriceItemCount` | `integer` | yes | Open items in another currency than the organization’s. Counted and never summed: an item keeps the currency it was written in and is never revalued, so no amount of theirs belongs in a figure stated in this currency, and an invoice reports them as `other-currency` rather than billing them. | | `oldestEntryAt` | `string (date-time) \| null` | yes | Start of the oldest still-open entry at this level — the signal, not a sum. `null` when nothing open here is an entry, which is what a level whose only open items are monthly time allowances or fixed-price items looks like: neither has an instant, and naming one would point at a day nothing happened on. | | `projects` | `UnbilledProject[]` | yes | | ### UnbilledProject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `projectId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `projectName` | `string` | yes | | | `archived` | `boolean` | yes | An archived project always stays listed while it still owes hours — there is no way to narrow it out. Write off its hours instead (ADR-2026-09-21-108YB). | | `joinableRules` | `object[]` | yes | The customer’s active selected-projects billing rules this project could join over a gap in its coverage that holds at least one of its open services — an entry portion or an allowance month. Empty when no such gap holds any, or the customer has no such rule (ADR-2026-10-07-119RC decision 6). | | `joinableRules.ruleId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `joinableRules.ruleName` | `string` | yes | | | `seconds` | `integer` | yes | Billable seconds with a resolvable rate, no live invoice claim over them — what `amountMinor` is built from. | | `amountMinor` | `integer` | yes | Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view. | | `currency` | `string` | yes | ISO-4217 currency code. | | `unratedSeconds` | `integer` | yes | Billable hours with no resolvable rate. Reported, never billed at zero — and counted per budget slice, so an entry half of which a budget rule prices contributes only its unpriced half. A monthly time allowance („Pauschale") counts here whole: a month is billed whole or not at all, so one unpriced share makes the month unbillable entire. | | `allowanceSeconds` | `integer` | yes | How many of `seconds` are monthly time allowances („Pauschale") rather than tracked time. Part of `seconds`, not beside it — it is open, billable time — and named because these are hours no time entry backs, so the entries behind this row add up to less than it says. | | `fixedPriceItemCount` | `integer` | yes | Fixed-price items („Posten") on no live invoice, in the organization’s currency, upcoming ones included. An item is an amount billed whole, so it has a count and an amount and no seconds; a released claim (a withdrawn invoice) does not hold it. | | `fixedPriceAmountMinor` | `integer` | yes | The sum of those items’ own amounts, each rounded once when it was written. Stands **beside** `amountMinor` and is never part of it — that figure is rounded per (project, rate) from seconds — so a client states the two together or says which it shows (invariant 19). | | `upcomingFixedPriceItemCount` | `integer` | yes | How many of `fixedPriceItemCount` are not completed yet: the item’s last service day lies after today in the organization’s zone. Part of it, not beside it. | | `upcomingFixedPriceAmountMinor` | `integer` | yes | The amount of those upcoming items. Part of `fixedPriceAmountMinor`, named so a screen can mark it as upcoming instead of owed. | | `otherCurrencyFixedPriceItemCount` | `integer` | yes | Open items in another currency than the organization’s. Counted and never summed: an item keeps the currency it was written in and is never revalued, so no amount of theirs belongs in a figure stated in this currency, and an invoice reports them as `other-currency` rather than billing them. | | `oldestEntryAt` | `string (date-time) \| null` | yes | Start of the oldest still-open entry at this level — the signal, not a sum. `null` when nothing open here is an entry, which is what a level whose only open items are monthly time allowances or fixed-price items looks like: neither has an instant, and naming one would point at a day nothing happened on. | ### UnbilledSummary **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `throughMonth` | `string \| null` | yes | The last month these figures counted — `null` when nothing was left out, which is the default. Resolved in the organization’s time zone, so a client states the period it is looking at instead of deriving a month boundary of its own (ADR-2026-09-07-15TM4). | | `total` | `UnbilledTotal` | yes | | | `customers` | `UnbilledCustomer[]` | yes | | ### UnbilledTotal **Fields** | Field | Type | Required | Description | |---|---|---|---| | `seconds` | `integer` | yes | Billable seconds with a resolvable rate, no live invoice claim over them — what `amountMinor` is built from. | | `amountMinor` | `integer` | yes | Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view. | | `currency` | `string` | yes | ISO-4217 currency code. | | `unratedSeconds` | `integer` | yes | Billable hours with no resolvable rate. Reported, never billed at zero — and counted per budget slice, so an entry half of which a budget rule prices contributes only its unpriced half. A monthly time allowance („Pauschale") counts here whole: a month is billed whole or not at all, so one unpriced share makes the month unbillable entire. | | `allowanceSeconds` | `integer` | yes | How many of `seconds` are monthly time allowances („Pauschale") rather than tracked time. Part of `seconds`, not beside it — it is open, billable time — and named because these are hours no time entry backs, so the entries behind this row add up to less than it says. | | `fixedPriceItemCount` | `integer` | yes | Fixed-price items („Posten") on no live invoice, in the organization’s currency, upcoming ones included. An item is an amount billed whole, so it has a count and an amount and no seconds; a released claim (a withdrawn invoice) does not hold it. | | `fixedPriceAmountMinor` | `integer` | yes | The sum of those items’ own amounts, each rounded once when it was written. Stands **beside** `amountMinor` and is never part of it — that figure is rounded per (project, rate) from seconds — so a client states the two together or says which it shows (invariant 19). | | `upcomingFixedPriceItemCount` | `integer` | yes | How many of `fixedPriceItemCount` are not completed yet: the item’s last service day lies after today in the organization’s zone. Part of it, not beside it. | | `upcomingFixedPriceAmountMinor` | `integer` | yes | The amount of those upcoming items. Part of `fixedPriceAmountMinor`, named so a screen can mark it as upcoming instead of owed. | | `otherCurrencyFixedPriceItemCount` | `integer` | yes | Open items in another currency than the organization’s. Counted and never summed: an item keeps the currency it was written in and is never revalued, so no amount of theirs belongs in a figure stated in this currency, and an invoice reports them as `other-currency` rather than billing them. | ### UpdateAbsence **Fields** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | no | | | `fromDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `secondsPerDay` | `integer \| null` | no | | | `fractionOfDay` | `number` | no | A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`. | | `note` | `string \| null` | no | | | `editReason` | `string` | no | Why an absence that sits behind the organization’s tracking deadline — or would be moved there — is being changed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history. | ### UpdateBillingEscalation **Fields** | Field | Type | Required | Description | |---|---|---|---| | `delayDays` | `integer` | yes | Calendar days after a proposal’s due date before the global recipient joins the rule’s own responsible people, and before weekly repeats begin. | | `recipientPersonId` | `string (uuid) \| null` | yes | The second-level recipient every escalated or weekly-repeated proposal email also reaches. `null` leaves the obligation visibly unset rather than silently dropped. | ### UpdateBillingRuleSchedule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | A human-readable name for the rule. | | `dueDayOfMonth` | `integer` | no | The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. | | `selectionMode` | `"period-only" \| "cumulative"` | no | `period-only` bills the completed period alone. `cumulative` also includes older outstanding services through the period’s end, subject to the unique proposal allocation rule. | | `defaultGrouping` | `"member" \| "project" \| "task" \| "entry"` | no | The default invoice grouping a proposal generated under this rule freezes. | | `responsiblePersonIds` | `string (uuid)[]` | no | The people who receive due/escalation email for this rule. | ### UpdateBudget **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `validFromMonth` | `string` | no | A calendar month, `YYYY-MM`. | | `validUntilMonth` | `string \| null` | no | A calendar month, `YYYY-MM`. | | `budgetedSeconds` | `integer` | no | The agreed hours as whole seconds. 150 h/month is 540000. Per covered month for `kind: monthly`, one figure for the whole span for `kind: total`. | | `kind` | `"monthly" \| "total"` | no | How the agreed hours are measured: `monthly` per covered month, `total` once over the whole `validFromMonth…validUntilMonth` span. A `total` budget has no per-month adjustments — its capacity is one figure, corrected via `PATCH` or superseded. | | `overflowIntoId` | `string (uuid) \| null` | no | Where hours beyond this budget’s capacity count instead — the one sanctioned overlap: hours fill this budget first and everything beyond draws down the named pot, chronologically and exact to the second. `null` means overflow stays here and the difference goes visibly negative. The target must be a live budget of the same customer. One pointer per budget (fan-out is inexpressible); several budgets may name the same pot (fan-in); a cycle is refused. | | `projectIds` | `string (uuid)[]` | no | | ### UpdateCapacityAgreement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `validFromDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `kind` | `"share" \| "fixed"` | no | share = a percentage of contracted time, which follows a contract change by itself. fixed = an absolute sold week, which survives one by itself. | | `sharePercent` | `string \| null` | no | | | `soldSecondsPerWeek` | `integer \| null` | no | | | `employment` | `"internal" \| "external"` | no | internal (employed) or external (freelancer). | | `inTeamMetric` | `boolean` | no | | | `note` | `string \| null` | no | | ### UpdateCustomer **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `code` | `string \| null` | no | | | `color` | `string` | no | | ### UpdateDashboard **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `visibility` | `"private" \| "shared"` | no | private = readable by its author alone. shared = readable by everybody who may open dashboards at all, which is this organization’s owners and admins. There is no per-person sharing. | | `layout` | `DashboardLayout` | no | | ### UpdateFixedPriceAgreement **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | ### UpdateFixedPriceItem **Fields** | Field | Type | Required | Description | |---|---|---|---| | `agreementId` | `string (uuid) \| null` | no | Resource identifier (UUID v4). | | `projectId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `description` | `string` | no | | | `quantityHundredths` | `integer` | no | | | `unit` | `string` | no | | | `unitPriceMinor` | `integer` | no | | | `easybillPositionId` | `string \| null` | no | The Easybill article overriding the kind’s own; omit or `null` for the kind’s article. Whether Easybill knows it is answered at the export. | | `days` | `string[]` | no | | | `people` | `object[]` | no | | | `people.personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `people.weight` | `integer` | yes | The share of the item’s revenue, relative to the other people. Equal by default. | | `editReason` | `string` | no | Why an item is being changed on a closed day anyway. The days it is on now **and** the days it would move to must be open. Required — 422 carrying `reasonRequired: true` — when the write gets through a closed day only because the caller is an owner or admin (`time.editBilled`); ignored otherwise. Kept on the revision this write appends. | ### UpdateGoogleCalendar **Fields** | Field | Type | Required | Description | |---|---|---|---| | `calendarIds` | `string[]` | no | Which calendars are observed. An empty array is valid and means nothing is read — there is no value meaning "all". | | `includeDetails` | `boolean` | no | Whether a hint may carry the calendar's name, the event's location and the first line of its description. Off by default; turning it off also clears what is already stored. | ### UpdateInvoice **Fields** | Field | Type | Required | Description | |---|---|---|---| | `notes` | `string \| null` | yes | | ### UpdateMemberDailyReport **Fields** | Field | Type | Required | Description | |---|---|---|---| | `dailyOrgReportEmailEnabled` | `boolean` | no | | | `dailyOrgReportSendWeekdays` | `integer[]` | no | | | `dailyOrgReportSendHour` | `integer` | no | | ### UpdateOrganizationFeatureSwitches **Fields** | Field | Type | Required | Description | |---|---|---|---| | `planning` | `boolean` | no | | | `customer-portal` | `boolean` | no | | | `tickets` | `boolean` | no | | | `absences` | `boolean` | no | | | `planningReadConsent` | `"ask-again" \| "extend"` | no | What switching `absences` back on does to the connected clients approved with `thymian:planning.read`, which then reach absences. `ask-again` (the default) withdraws their tokens, so each is answered 401 and asked to approve again; `extend` lets their approval reach absences as it is. Either way the persons concerned, other than the caller, are told by email. Ignored unless absences actually come back on. | ### UpdateOrganizationSettings **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | no | ISO-4217 currency code. | | `rateDisplay` | `"hourly" \| "daily"` | no | How rates are presented in the UI. Does not affect stored data. | | `hoursPerDay` | `string` | no | | | `timeZone` | `string` | no | | | `weekStartsOn` | `integer` | no | | | `hintRetentionDays` | `integer` | no | Days a hint is kept before `POST /hints` prunes it. Enforced when a collector reports, not by a scheduler. | | `runningTimerAvatarsEnabled` | `boolean` | no | | | `tourEnabled` | `boolean` | no | | | `jamiEnabled` | `boolean` | no | | | `llmHintEvaluationEnabled` | `boolean` | no | | ### UpdateProject **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `aliases` | `string[]` | no | Alternative names. They are included in resource search and when filtering task pickers. | | `visibility` | `"public" \| "private"` | no | Who may see the project. `public` is the whole organization. | | `color` | `string \| null` | no | | ### UpdateSlackIdentity **Fields** | Field | Type | Required | Description | |---|---|---|---| | `includeMessageText` | `boolean` | yes | Whether a hint may carry the first line of what you wrote. Off by default; turning it off also clears the text already stored. | ### UpdateSlackStatus **Fields** | Field | Type | Required | Description | |---|---|---|---| | `statusTemplate` | `string \| null` | no | Placeholders `{customer}`, `{project}` and `{task}` are filled in. `null` restores the neutral default, which names no customer. | | `statusEmoji` | `string \| null` | no | `null` restores `:stopwatch:`. | ### UpdateTask **Fields** | Field | Type | Required | Description | |---|---|---|---| | `name` | `string` | no | | | `billingMode` | `"hourly" \| "fixed_price" \| "productive" \| "internal"` | no | The billing mode to set. `fixed_price` needs `fixedPriceAgreementId` in the same request or already on the task, and `hourly` and `productive` forbid one. Sent together with `isBillable` or `isProductive`, the values must agree (`isBillable` is `billingMode === "hourly"`), otherwise the write answers 422. | | `fixedPriceAgreementId` | `string (uuid) \| null` | no | Attach the task to a fixed-price agreement, or `null` to detach it. `fixed_price` requires one, `hourly` and `productive` forbid one, and `internal` may carry one. Neither this nor `billingMode` changes once the task has entries, for any task that has or would get an agreement: answers **409**, with no owner or admin exception. | | `isBillable` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. `true` makes the task `hourly`. `false` makes it `internal`, or leaves it `productive` when it already is. | | `isProductive` **(deprecated)** | `boolean` | no | Deprecated, use `billingMode`. Derived: `billingMode === "productive"`. Whether this task is value-creating work that is never invoiced. Never true together with isBillable. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | | `isCatchAll` | `boolean` | no | Whether this task is the catch-all people book to when nothing else fits. Setting or changing it needs the `company-metrics` feature; otherwise the write answers 403 feature-not-enabled. | ### UpdateTimeAllowance **Fields** | Field | Type | Required | Description | |---|---|---|---| | `secondsPerMonth` | `integer` | no | The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. | | `validFromMonth` | `string` | no | The first month the agreement covers, `YYYY-MM`. Zero-padded, so a month sorts lexicographically exactly as it sorts chronologically — which is what the database’s overlap constraint and every span comparison rely on. | | `validUntilMonth` | `string \| null` | no | The last month the agreement covers, **inclusive**, or `null` for open-ended. Set by a supersession: raising 60 minutes to 120 closes September and opens its successor in October. | | `note` | `string \| null` | no | Why this was agreed, in free text. `null` clears it. „2 h/Monat" without a note is a riddle in half a year. | ### UpdateTimeEntry **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid)` | no | Resource identifier (UUID v4). | | `startedAt` | `string (date-time)` | no | Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `endedAt` | `string (date-time) \| null` | no | `null` turns a finished entry back into a running one. Stored at **second** resolution: milliseconds are floored away (never rounded up), silently. A stated second is a claim somebody could have made — a millisecond is the arrival time of a request. | | `notes` | `string \| null` | no | | | `editReason` | `string` | no | Why hours that are already billed are being changed anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the request changes `startedAt`, `endedAt` or `taskId` on an entry a live claim is holding, or moves such a fact into or out of a **closed tracking period**, and gets through only because the caller holds `time.editBilled`. Whitespace does not count. Ignored on every other save. It is kept with the revision this write appends, readable in the entry’s history and beside the entry in `GET /invoices/divergences`. | ### UpdateTrackingWindow **Fields** | Field | Type | Required | Description | |---|---|---|---| | `trackingOpenMonths` | `integer \| null` | yes | `null` removes the limit; the setting is off by default. | ### UpdateUserPreferences **Fields** | Field | Type | Required | Description | |---|---|---|---| | `rateDisplay` | `"hourly" \| "daily" \| null \| null` | no | How rates are presented in the UI. Does not affect stored data. | | `locale` | `string` | no | | | `timeZone` | `string \| null` | no | | | `llmHintEvaluationConsented` | `boolean` | no | | | `keyboardShortcutsEnabled` | `boolean` | no | | | `runningTimerAvatarsEnabled` | `boolean` | no | | | `tourEnabled` | `boolean` | no | | | `jamiEnabled` | `boolean` | no | | | `forgottenTimerEmailEnabled` | `boolean` | no | | | `forgottenTimerThresholdSeconds` | `integer` | no | | | `operatorNewUserEmailEnabled` | `boolean` | no | | | `operatorNewOrganizationEmailEnabled` | `boolean` | no | | | `organizationNewMemberEmailEnabled` | `boolean` | no | | | `dailyOrgReportEmailEnabled` | `boolean` | no | | | `dailyOrgReportSendWeekdays` | `integer[]` | no | | | `dailyOrgReportSendHour` | `integer` | no | | | `budgetAlertEmailEnabled` | `boolean` | no | | | `dayTab` | `"list" \| "calendar" \| "max"` | no | „Heute“’s tabs: `list` („Liste“), `calendar` („Kalender“) and `max` („Max 2.0“). | | `hintMaxViewEnabled` | `boolean` | no | | | `hintCorrectionsEnabled` | `boolean` | no | | | `hintBoundariesEnabled` | `boolean` | no | | | `hintMinDeviationSeconds` | `integer` | no | | | `hintMinDurationSeconds` | `integer` | no | | | `hintConfirmationsEnabled` | `boolean` | no | | | `hintInboxEnabled` | `boolean` | no | | | `hintEvidenceLabelsEnabled` | `boolean` | no | | | `hintExcludedSources` | `string[]` | no | | ### UpdateWorkSchedule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `validFromDay` | `string` | no | A calendar day, `YYYY-MM-DD`. | | `kind` | `"weekly" \| "monthly"` | no | | | `mondaySeconds` | `integer \| null` | no | | | `tuesdaySeconds` | `integer \| null` | no | | | `wednesdaySeconds` | `integer \| null` | no | | | `thursdaySeconds` | `integer \| null` | no | | | `fridaySeconds` | `integer \| null` | no | | | `saturdaySeconds` | `integer \| null` | no | | | `sundaySeconds` | `integer \| null` | no | | | `monthlySeconds` | `integer \| null` | no | | | `note` | `string \| null` | no | | ### UpsertAbsence **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | no | Whose absence. Defaults to the caller; naming somebody else — including moving an existing absence to somebody else — needs owner or admin. | | `userId` **(deprecated)** | `string` | no | Deprecated alias for `personId`, carrying a **better-auth account id** rather than a person id; the server resolves it through the person that account belongs to. Send `personId` instead. Exclusive with `personId`. | | `kind` | `"vacation" \| "sick" \| "holiday" \| "other"` | yes | | | `fromDay` | `string` | yes | A calendar day, `YYYY-MM-DD`. | | `untilDay` | `string` | yes | Inclusive. Equal to `fromDay` for a single day. | | `secondsPerDay` | `integer \| null` | no | Omit or send `null` for whole days. Omitting it on an absence that had one **clears** it: this is the whole absence, not a patch. | | `fractionOfDay` | `number` | no | A part-day absence as a share of the day’s own agreed length — 0.5 for half a day — resolved once, at write time, against the person’s work schedule (or the organization’s `hoursPerDay` where none exists) into `secondsPerDay`. Only valid where `fromDay` equals `untilDay`, and mutually exclusive with `secondsPerDay`. | | `note` | `string \| null` | no | Omitting it clears an existing note, for the reason `secondsPerDay` gives. | | `editReason` | `string` | no | Why an absence is being imported behind the organization’s tracking deadline anyway. **Required** — 422, naming this field and carrying `reasonRequired: true` — when the write gets through only because the caller holds `absence.editLocked`; ignored otherwise. Whitespace does not count. Kept on the revision this write appends, and shown in the absence’s history. | ### UpsertProjectMember **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | ### UsageKey **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `name` | `string \| null` | yes | | | `start` | `string \| null` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `lastRequestAt` | `string (date-time) \| null` | yes | ISO-8601 timestamp with offset. | | `source` | `"claude-code" \| "codex"` | yes | The coding agent a usage key reports for. | | `device` | `string` | yes | | ### UserHours **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `totalSeconds` | `integer` | yes | | | `lastEntryAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### UserPreferences **Fields** | Field | Type | Required | Description | |---|---|---|---| | `rateDisplay` | `"hourly" \| "daily" \| null \| null` | yes | How rates are presented in the UI. Does not affect stored data. | | `locale` | `string` | yes | | | `timeZone` | `string \| null` | yes | | | `llmHintEvaluationConsented` | `boolean` | yes | Whether this person permits manual LLM evaluation of their own hints. | | `keyboardShortcutsEnabled` | `boolean` | yes | Whether keyboard shortcuts and their visual key hints are enabled. | | `runningTimerAvatarsEnabled` | `boolean` | yes | Whether this person's own header shows colleagues' running-timer avatars. On by default; only takes effect while the organization's own setting is also on — turning this on cannot bring the header back once the organization has turned it off. | | `tourEnabled` | `boolean` | yes | Whether the web app's onboarding tour appears for this person. On by default; only takes effect while the organization has the `tour` feature and its own setting is on (`effectiveTourEnabled`). | | `jamiEnabled` | `boolean` | yes | Whether Jami guides the tour for this person. On by default; only takes effect while the organization has the `jami` feature in effect, its own setting is on, and the tour appears for this person (`effectiveJamiEnabled`). Without him the tour speaks in neutral text and draws no figure. Off, it also keeps his notes on what changed away (`effectiveJamiNotesEnabled`). | | `forgottenTimerEmailEnabled` | `boolean` | yes | Whether the forgotten-running-timer rule may deliver an email. | | `forgottenTimerThresholdSeconds` | `integer` | yes | How long a timer must run uninterrupted before it counts as unusually long. The rule has a second trigger this does not govern: a timer still running well past the hour this person usually stops at fires on its own, earlier. | | `operatorNewUserEmailEnabled` | `boolean` | yes | Whether a current platform operator receives email when an account is created. | | `operatorNewOrganizationEmailEnabled` | `boolean` | yes | Whether a current platform operator receives email when an organization is created. | | `organizationNewMemberEmailEnabled` | `boolean` | yes | Whether a current organization owner or admin receives email when a member joins. | | `dailyOrgReportEmailEnabled` | `boolean` | yes | Whether a current holder of report.readOthers receives the daily organization revenue digest. | | `dailyOrgReportSendWeekdays` | `integer[]` | yes | ISO weekdays (1 = Monday … 7 = Sunday) the digest is sent on. The covered period is derived from this schedule, not stored separately. | | `dailyOrgReportSendHour` | `integer` | yes | The hour, in the organization's own time zone, the digest is sent at on a scheduled day. Only delays the send within that day — never changes which days it covers. | | `budgetAlertEmailEnabled` | `boolean` | yes | Whether this person is mailed when a budget reaches a configured threshold. Off unless turned on; a budget the person cannot see is never mailed about, whatever this says. | | `dayTab` | `"list" \| "calendar" \| "max" \| null \| null` | yes | The tab „Heute“ opens on: the one this account last chose, on any device. `null` until one was chosen, which opens `list`. A remembered `max` whose tab is no longer offered opens `list`. | | `hintMaxViewEnabled` | `boolean` | yes | Whether „Heute“ offers the „Max 2.0“ tab, which shows the day with its hints in it, beside „Liste“ and „Kalender“. The other hint* settings shape that tab alone. Off unless turned on. | | `hintCorrectionsEnabled` | `boolean` | yes | Whether „Heute“ marks a finished entry that this person's own hints contradict, with a one-click correction. | | `hintBoundariesEnabled` | `boolean` | yes | Whether „Heute“ offers presence observed before the first or after the last entry of the day as the start or end of the workday. | | `hintMinDeviationSeconds` | `integer` | yes | On „Heute“, an entry’s own task continuing past it, a workday edge or a correction shorter than this many seconds is not offered. | | `hintMinDurationSeconds` | `integer` | yes | A rule-named hint observed for less than this many seconds is left out of „Heute“ altogether; 0 shows every hint. Presence and unclassified hints are never left out. | | `hintConfirmationsEnabled` | `boolean` | yes | Whether „Heute“ names the hint sources that agree with a finished entry. | | `hintInboxEnabled` | `boolean` | yes | Whether „Heute“ lists hint subjects that are not classified yet. | | `hintEvidenceLabelsEnabled` | `boolean` | yes | Whether a hint row on „Heute“ names its sources and subjects as text, or shows only their icons. | | `hintExcludedSources` | `string[]` | yes | Hint sources „Heute“ leaves out. Presentation only: the hints themselves and GET /hints are unaffected. | ### UserProjectCount **Fields** | Field | Type | Required | Description | |---|---|---|---| | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `projectCount` | `integer` | yes | | ### UserSession **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string` | yes | | | `current` | `boolean` | yes | | | `expiresAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `ipAddress` | `string \| null` | yes | | | `userAgent` | `string \| null` | yes | | | `clientType` | `string` | yes | | ### UserSessions Type: `UserSession[]` ### VisualProvenance **Fields** | Field | Type | Required | Description | |---|---|---|---| | `kind` | `"project" \| "customer"` | yes | | | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `name` | `string` | yes | | ### WatchCredential **Fields** | Field | Type | Required | Description | |---|---|---|---| | `token` | `string` | yes | | | `expiresAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### WidgetPushToken **Fields** | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"ios" \| "watchos"` | yes | | | `kind` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `lastSeenAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### WidgetPushTokenRegistration **Fields** | Field | Type | Required | Description | |---|---|---|---| | `platform` | `"ios" \| "watchos"` | yes | | | `kind` | `string` | yes | | | `environment` | `"sandbox" \| "production"` | yes | | | `token` | `string` | yes | | ### WorkSchedule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `validFromDay` | `string` | yes | The day this version starts holding. The next version ends the previous one — there is no `validUntilDay`. | | `kind` | `"weekly" \| "monthly"` | yes | `weekly`: the seven weekday fields are the agreement. `monthly`: `monthlySeconds` is, and a working day’s own share is derived. | | `mondaySeconds` | `integer \| null` | yes | | | `tuesdaySeconds` | `integer \| null` | yes | | | `wednesdaySeconds` | `integer \| null` | yes | | | `thursdaySeconds` | `integer \| null` | yes | | | `fridaySeconds` | `integer \| null` | yes | | | `saturdaySeconds` | `integer \| null` | yes | | | `sundaySeconds` | `integer \| null` | yes | | | `monthlySeconds` | `integer \| null` | yes | Set for `kind: monthly` only. | | `note` | `string \| null` | yes | | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `updatedAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | ### WriteOff **Fields** | Field | Type | Required | Description | |---|---|---|---| | `id` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `reason` | `string` | yes | Why these hours were waived. Required when the batch is created. | | `createdAt` | `string (date-time)` | yes | ISO-8601 timestamp with offset. | | `releasedAt` | `string (date-time) \| null` | yes | Set once the write-off was taken back. The record stays and stays listed; its seconds went back to „Abzurechnen". | | `createdByPersonId` | `string (uuid) \| null` | yes | Null once the person row itself was deleted — the record outlives them. Deleting the *account* leaves the person, and this id, in place. | | `createdByName` | `string \| null` | yes | | | `currency` | `string` | yes | The claims’ frozen currency, not the organization’s today: changing the currency revalues un-invoiced hours and leaves settled records where they are. | | `seconds` | `integer` | yes | | | `amountMinor` | `integer` | yes | | | `entryCount` | `integer` | yes | | | `claimCount` | `integer` | yes | | | `rates` | `WriteOffRateTotal[]` | yes | What was waived per rate. The list carries no per-claim rows — a batch over an abandoned project can hold hundreds, and the figures a screen shows are these. | ### WriteOffClaim **Fields** | Field | Type | Required | Description | |---|---|---|---| | `entryId` | `string (uuid)` | yes | Resource identifier (UUID v4). | | `seconds` | `integer` | yes | The seconds this claim waives — the free stretch of the entry that this rate priced, never the entry’s whole duration. | | `rateMinor` | `integer` | yes | What these seconds would have been billed at, in minor units per hour. Never null: a write-off records the amount it waives, which is the decision being made (ADR-2026-08-24-11D67 decision 4). | | `amountMinor` | `integer` | yes | What was waived, rounded **once over this claim’s own seconds** — the write-off’s own rounding rule, deliberately not an invoice line’s. | | `startedAt` | `string (date-time)` | yes | The entry’s start, for the row’s label. | | `personId` | `string (uuid)` | yes | Person identifier (UUID v4) — a person, not a login. | | `personName` | `string` | yes | | | `customerName` | `string` | yes | | | `projectName` | `string` | yes | | | `taskName` | `string` | yes | | ### WriteOffCut **Fields** | Field | Type | Required | Description | |---|---|---|---| | `currency` | `string` | yes | ISO-4217 currency code. | | `seconds` | `integer` | yes | Everything waived, over every rate. | | `amountMinor` | `integer` | yes | The sum of the per-rate rows, which are sums of per-claim amounts. | | `entryCount` | `integer` | yes | How many entries contribute at least one claim. | | `rates` | `WriteOffRateTotal[]` | yes | | | `claims` | `WriteOffClaim[]` | yes | | ### WriteOffList **Fields** | Field | Type | Required | Description | |---|---|---|---| | `data` | `WriteOff[]` | yes | | | `nextCursor` | `string \| null` | yes | Cursor for the next page, or null when the list is exhausted. | ### WriteOffRateTotal **Fields** | Field | Type | Required | Description | |---|---|---|---| | `rateMinor` | `integer` | yes | | | `seconds` | `integer` | yes | | | `amountMinor` | `integer` | yes | The sum of this rate’s claims, each already rounded on its own — never the summed seconds rounded again, or a row and the claims under it would be cents apart. | | `claimCount` | `integer` | yes | | ### WritePolicy **Fields** | Field | Type | Required | Description | |---|---|---|---| | `permission` | `"free" \| "reason" \| "locked"` | yes | `free` — nothing is in the way. `reason` — allowed, and the save must carry `editReason`. `locked` — refused for this caller whatever they type. | | `blockers` | `PolicyBlocker[]` | yes | | ### WriteRateRule **Fields** | Field | Type | Required | Description | |---|---|---|---| | `taskId` | `string (uuid) \| null` | no | The task this rule is for. Omit or null for any task. | | `personId` | `string (uuid) \| null` | no | The person this rule is for. Omit or null for anybody. | | `budgetId` | `string (uuid) \| null` | no | The budget whose hours this rule prices. Omit or null for any budget. Must be a budget that can hold this project’s hours — the ones covering it, plus anything downstream of them by overflow — or the request is refused: a rule naming a pot the project cannot reach prices nothing, and a silent blank is what this model exists to avoid. | | `validFromMonth` | `string \| null` | no | The month of the version this rule belongs to. Omit or null for "from the beginning" — an assertion somebody makes, not a default the server invents. | | `hourlyRateMinor` | `integer` | yes | Hourly rate in minor units. Never null — leave the rule out instead. |