Timjami API

Time tracking for more than one organization at once. Every domain operation below is read out of the domain OpenAPI document on the request that draws it. Better Auth publishes the separate Auth & organizations contract linked beside it, so both describe the server that answered and not a build somebody remembered to run.

Base URL
https://timjami.app/api/v1
For an LLM

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:

curl -s https://timjami.app/api/v1/projects \
  -H 'Authorization: Bearer thyp_…'
{
  "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:

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.

ScopeWhat it grantsOperationsOAuth
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 —
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 —
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 —
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 —
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

203 operations accept a session and no scoped credential at all; 3 need no domain scope beyond authenticating. Counted from the served document, not maintained by hand.

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 <month>:<entryId>:<fromSecond> — 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

# 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

// 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:

#!/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:

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:

{
  "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, from this document — so the number goes up the moment somebody adds a route without describing it, and nobody has to remember to update it.

Operations with a summary and nothing else56 / 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.

  • POST /api/v1/me/watch-credential
  • DELETE /api/v1/me/watch-credential
  • GET /api/v1/organization/demo-data
  • GET /api/v1/organization/domain
  • PUT /api/v1/organization/domain
  • PATCH /api/v1/organization/domain
  • DELETE /api/v1/organization/domain
  • POST /api/v1/organization/domain/verify
  • GET /api/v1/organization/login-policy
  • POST /api/v1/customers
  • GET /api/v1/customers/{id}
  • PATCH /api/v1/customers/{id}
  • POST /api/v1/projects/{id}/archive
  • DELETE /api/v1/projects/{id}/members/{personId}
  • GET /api/v1/tasks/{id}
  • POST /api/v1/tasks/{id}/archive
  • GET /api/v1/time-entries/{id}
  • POST /api/v1/time-entries/{id}/stop
  • GET /api/v1/invoices/{id}
  • DELETE /api/v1/projects/{id}/harvest
  • DELETE /api/v1/tasks/{id}/harvest
  • GET /api/v1/hints/{id}
  • GET /api/v1/auth/providers
  • GET /api/v1/billing-proposals/{id}
  • GET /api/v1/billing-rules
  • GET /api/v1/billing-rules/{id}
  • POST /api/v1/billing-rules/{id}/resume
  • DELETE /api/v1/break-periods/{id}
  • GET /api/v1/budgets/{id}
  • GET /api/v1/changelog/{kind}
  • GET /api/v1/customer-portal/{organizationSlug}
  • GET /api/v1/customer-portal/{organizationSlug}/branding
  • GET /api/v1/organization/customer-portal
  • PUT /api/v1/organization/customer-portal/branding
  • POST /api/v1/organization/customer-portal/invitations
  • DELETE /api/v1/organization/customer-portal/invitations/{id}
  • PUT /api/v1/organization/customer-portal/projects
  • GET /api/v1/downloads
  • GET /api/v1/downloads/{kind}
  • POST /api/v1/downloads/cli/install-tickets
  • GET /api/v1/downloads/desktop/updater-artifact/{tag}
  • GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion}
  • GET /api/v1/downloads/firefox-extension/updates.json
  • GET /api/v1/downloads/firefox-extension/xpi/{tag}
  • GET /api/v1/fixed-price-agreements/{id}
  • GET /api/v1/fixed-price-items/{id}
  • GET /api/v1/operator-security
  • POST /api/v1/operator-security/authentication-options
  • POST /api/v1/operator-security/passkeys
  • DELETE /api/v1/operator-security/passkeys/{id}
  • POST /api/v1/operator-security/registration-options
  • GET /api/v1/time-allowances/{id}
  • GET /api/v1/timer
  • DELETE /api/v1/timer/forgotten-timer-push-tokens
  • POST /api/v1/timer/start
  • POST /api/v1/write-offs/allowance
Groups with no description30 / 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.

  • Absence Subjects
  • Absences
  • Access
  • Admin
  • Diagnostics
  • App installations
  • Authentication
  • Avatars
  • Billing proposals
  • Billing rules
  • Break periods
  • Budgets
  • Capacity agreements
  • Changelog
  • Customer portal
  • Easybill
  • sevdesk
  • Dashboards
  • Downloads
  • Fixed-price work
  • Imports
  • Operator security
  • Personio
  • Persons
  • Planning
  • Search
  • Time allowances
  • Timer
  • Work schedules
  • Write-offs
Parameters with no description70 / 434

A type tells you a parameter takes a string. It does not tell you what happens when you omit it.

  • sessionId — DELETE /api/v1/me/sessions/{sessionId}
  • includeArchived — GET /api/v1/customers
  • limit — GET /api/v1/customers
  • includeArchived — GET /api/v1/projects
  • visibility — GET /api/v1/projects
  • orderBy — GET /api/v1/projects
  • limit — GET /api/v1/projects
  • includeArchived — GET /api/v1/tasks
  • bookable — GET /api/v1/tasks
  • days — GET /api/v1/tasks/recent
  • limit — GET /api/v1/tasks/recent
  • isBillable — GET /api/v1/time-entries
  • limit — GET /api/v1/time-entries
  • from — GET /api/v1/time-entries/deleted
  • until — GET /api/v1/time-entries/deleted
  • isBillable — GET /api/v1/time-entries/export
  • days — GET /api/v1/time-entries/recent-notes
  • limit — GET /api/v1/time-entries/recent-notes
  • limit — GET /api/v1/time-entries/revisions
  • from — GET /api/v1/time-entries/summary
  • until — GET /api/v1/time-entries/summary
  • limit — GET /api/v1/invoices
  • limit — GET /api/v1/invoices/status
  • harvestTimeEntryId — DELETE /api/v1/harvest/links/by-harvest-entry/{harvestTimeEntryId}
  • harvestProjectId — GET /api/v1/harvest/projects/{harvestProjectId}/tasks
  • limit — GET /api/v1/reports/projection
  • limit — GET /api/v1/reports/projection/changes
  • provider — GET /api/v1/reports/tickets
  • provider — GET /api/v1/reports/tickets/days
  • limit — GET /api/v1/hints
  • id — DELETE /api/v1/collectors/keys/{id}
  • id — DELETE /api/v1/collectors/usage-keys/{id}
  • id — DELETE /api/v1/access-tokens/{id}
  • limit — GET /api/v1/absences/revisions
  • id — GET /api/v1/admin/organizations/{id}
  • id — DELETE /api/v1/admin/organizations/{id}
  • id — PUT /api/v1/admin/organizations/{id}/analytics-exclusion
  • id — DELETE /api/v1/admin/organizations/{id}/analytics-exclusion
  • id — PUT /api/v1/admin/organizations/{id}/features/{feature}
  • id — DELETE /api/v1/admin/organizations/{id}/features/{feature}
  • id — DELETE /api/v1/admin/organizations/{id}/users/{userId}
  • id — PUT /api/v1/admin/organizations/{id}/users/{userId}/features/{feature}
  • id — DELETE /api/v1/admin/organizations/{id}/users/{userId}/features/{feature}
  • status — GET /api/v1/billing-rules
  • kind — GET /api/v1/changelog/{kind}
  • kind — GET /api/v1/changelog/{kind}/status
  • organizationSlug — GET /api/v1/customer-portal/{organizationSlug}
  • organizationSlug — GET /api/v1/customer-portal/{organizationSlug}/branding
  • organizationSlug — GET /api/v1/customer-portal/{organizationSlug}/budgets
  • companyName — GET /api/v1/easybill/customers
  • page — GET /api/v1/easybill/customers
  • purpose — GET /api/v1/easybill/services
  • page — GET /api/v1/easybill/services
  • name — GET /api/v1/sevdesk/contacts
  • page — GET /api/v1/sevdesk/contacts
  • kind — GET /api/v1/downloads/{kind}
  • ticket — GET /api/v1/downloads/cli-native/install/{ticket}
  • ticket — GET /api/v1/downloads/cli-native/install/{ticket}/{kind}
  • kind — GET /api/v1/downloads/cli-native/install/{ticket}/{kind}
  • ticket — GET /api/v1/downloads/cli-native/install/{ticket}/powershell
  • ticket — GET /api/v1/downloads/cli/install/{ticket}
  • tag — GET /api/v1/downloads/desktop/updater-artifact/{tag}
  • target — GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion}
  • arch — GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion}
  • currentVersion — GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion}
  • tag — GET /api/v1/downloads/firefox-extension/xpi/{tag}
  • id — DELETE /api/v1/operator-security/passkeys/{id}
  • id — PUT /api/v1/personio-person-mappings/{id}
  • activityId — DELETE /api/v1/timer/live-activity-tokens/{activityId}
  • limit — GET /api/v1/write-offs
Response and request fields with no description1691 / 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.

  • Absence.kind
  • Absence.note
  • Absence.updatedAtPrecise
  • AbsenceList.data
  • AbsenceLogEntry.action
  • AbsenceLogEntry.actorName
  • AbsenceLogEntry.kind
  • AbsenceLogEntry.secondsPerDay
  • AbsenceLogEntry.note
  • AbsenceLogEntry.externalId
  • AbsenceLogEntry.reason
  • AbsenceLogHead.kind
  • AbsenceLogHead.secondsPerDay
  • AbsenceLogHead.note
  • AbsenceRevision.action
  • AbsenceRevision.position
  • AbsenceRevision.actorName
  • AbsenceRevision.kind
  • AbsenceRevision.secondsPerDay
  • AbsenceRevision.note
  • AbsenceRevision.externalId
  • AbsenceRevision.reason
  • AbsenceSubject.displayName
  • AccessRequest.name
  • AccessRequest.email
  • AccessRequest.company
  • AccessRequest.message
  • AccessToken.id
  • AccessToken.name
  • AccessToken.start
  • AccessToken.requestCount
  • AdminAccessRequest.id
  • AdminAccessRequest.name
  • AdminAccessRequest.email
  • AdminAccessRequest.company
  • AdminAccessRequest.message
  • AdminAccessRequest.status
  • AdminAccessRequestList.requests
  • AdminInviteCode.id
  • AdminInviteCode.code
  • AdminInviteCode.label
  • AdminInviteCode.email
  • AdminInviteCode.maxUses
  • AdminInviteCode.uses
  • AdminInviteCode.status
  • AdminInviteCode.redemptions
  • AdminInviteCodeList.codes
  • AgentTokenSums.input
  • AgentTokenSums.output
  • AgentTokenSums.cacheRead
  • AgentTokenSums.cacheWrite
  • AgentTokenSums.reasoning
  • AgentUsageSummary.month
  • AgentUsageSummary.personId
  • AgentUsageSummary.totals
  • AgentUsageSummary.bySource.tokens
  • AgentUsageSummary.byAttribution
  • AgentUsageSummary.byAttribution.attribution
  • AgentUsageSummary.byAttribution.tokens
  • AgentUsageSummary.byModel
  • AgentUsageSummary.byModel.model
  • AgentUsageSummary.byModel.tokens
  • AgentUsageSummary.byDay
  • AgentUsageSummary.byDay.day
  • AgentUsageSummary.byDay.tokens
  • AgentUsageSummary.unattributedPaths.pathLabels
  • AgentUsageSummary.unattributedPaths.sessionCount
  • AgentUsageSummary.unattributedPaths.tokens
  • AllowanceDivergence.invoiceStatus
  • AllowanceDivergence.customerName
  • AllowanceDivergence.customerIcon
  • AllowanceDivergence.customerIcon.contentType
  • AllowanceDivergence.customerIcon.width
  • AllowanceDivergence.customerIcon.height
  • AllowanceDivergence.customerIcon.byteSize
  • AllowanceDivergence.customerIcon.checksum
  • AllowanceDivergence.personName
  • AllowanceDivergence.projectName
  • AllowanceDivergence.taskName
  • AllowanceWriteOff.reason
  • AllowanceWriteOff.createdByName
  • AllowanceWriteOff.seconds
  • AllowanceWriteOff.amountMinor
  • AllowanceWriteOff.monthCount
  • AllowanceWriteOffCut.seconds
  • AllowanceWriteOffCut.amountMinor
  • AllowanceWriteOffCut.months
  • AllowanceWriteOffMonthCut.seconds
  • AllowanceWriteOffMonthCut.personName
  • AllowanceWriteOffMonthCut.projectName
  • AllowanceWriteOffMonthCut.taskName
  • AllowanceWriteOffMonthCut.amountMinor
  • AppInstallationCheckIn.installationId
  • AppInstallationCheckIn.app
  • AppInstallationCheckIn.version
  • AppInstallationCheckIn.platform
  • AppInstallationCheckIn.architecture
  • ApproveAccessRequestResult.code
  • AssignExternalDocument.selector
  • AttachExternalReference.provider
  • AuthProviders.google
  • BatchRefusal.type
  • BatchRefusal.title
  • BatchRefusal.detail
  • BatchSuggestColors.customerIds
  • BatchSuggestColorsResult.results
  • BatchTimeEntryResult.results
  • BatchTimeEntryResult.results.status
  • BatchTimeEntryResult.results.refusal
  • BatchTimeEntryResult.done
  • BatchTimeEntryResult.unchanged
  • BatchTimeEntryResult.failed
  • BillingAllocation.kind
  • BillingAllocation.fromSecond
  • BillingAllocation.toSecond
  • BillingAllocation.projectName
  • BillingAllocation.manuallyAssigned
  • BillingAllocation.deferReason
  • BillingAllocation.resolved
  • BillingAllocation.settledVia
  • BillingAllocationService.personName
  • BillingAllocationService.taskName
  • BillingAllocationService.notes
  • BillingProposal.customerName
  • BillingProposal.ruleName
  • BillingProposal.kind
  • BillingProposal.periodMonthCount
  • BillingProposal.unresolvedSeconds
  • BillingProposal.unresolvedAllowanceMonths
  • BillingProposal.unresolvedFixedPriceItems
  • BillingProposal.invoiceIds
  • BillingProposal.reopened
  • BillingProposalDetail.customerName
  • BillingProposalDetail.ruleName
  • BillingProposalDetail.kind
  • BillingProposalDetail.periodMonthCount
  • BillingProposalDetail.unresolvedSeconds
  • BillingProposalDetail.unresolvedAllowanceMonths
  • BillingProposalDetail.unresolvedFixedPriceItems
  • BillingProposalDetail.invoiceIds
  • BillingProposalDetail.reopened
  • BillingProposalDetail.allocations
  • BillingProposalListItem.customerName
  • BillingProposalListItem.ruleName
  • BillingProposalListItem.kind
  • BillingProposalListItem.periodMonthCount
  • BillingProposalListItem.unresolvedSeconds
  • BillingProposalListItem.unresolvedAllowanceMonths
  • BillingProposalListItem.unresolvedFixedPriceItems
  • BillingProposalListItem.invoiceIds
  • BillingProposalListItem.reopened
  • BillingProposalListItem.customerIcon
  • BillingProposalListItem.customerIcon.contentType
  • BillingProposalListItem.customerIcon.width
  • BillingProposalListItem.customerIcon.height
  • BillingProposalListItem.customerIcon.byteSize
  • BillingProposalListItem.customerIcon.checksum
  • BillingRule.customerName
  • BillingRule.name
  • BillingRule.status
  • BillingRule.coverage
  • BillingRule.coverage.projectName
  • BillingRuleListItem.customerName
  • BillingRuleListItem.name
  • BillingRuleListItem.status
  • BillingRuleListItem.coverage
  • BillingRuleListItem.coverage.projectName
  • BillingRuleListItem.customerIcon
  • BillingRuleListItem.customerIcon.contentType
  • BillingRuleListItem.customerIcon.width
  • BillingRuleListItem.customerIcon.height
  • BillingRuleListItem.customerIcon.byteSize
  • BillingRuleListItem.customerIcon.checksum
  • BillingRuleListItem.nextPeriod.periodMonthCount
  • BillingRuleListItem.latestProposal.kind
  • BillingRuleListItem.latestProposal.periodMonthCount
  • BillingRuleListItem.latestProposal.allocationCount
  • BillingRulePeriodPreview.currency
  • BillingRulePeriodPreview.periods
  • BookableTask.task
  • BookableTask.task.name
  • BookableTask.task.isBillable
  • BookableTask.task.icon
  • BookableTask.task.iconInheritedFrom
  • BookableTask.project
  • BookableTask.project.name
  • BookableTask.project.color
  • BookableTask.customer
  • BookableTask.customer.name
  • Budget.customerName
  • Budget.projects
  • Budget.projects.projectName
  • Budget.projects.archived
  • BudgetAlerts.thresholds
  • BudgetMonth.budgets
  • BudgetMonth.budgets.name
  • BudgetMonth.budgets.customerName
  • BudgetMonth.budgets.overflowIntoName
  • BudgetMonth.budgets.consumedSeconds
  • BudgetMonth.budgets.projects
  • BudgetMonth.unbudgeted
  • BudgetMonth.unbudgeted.projectName
  • BudgetMonth.unbudgeted.customerName
  • BudgetMonth.unbudgeted.consumedSeconds
  • BudgetMonth.overlaps
  • BudgetMonth.overlaps.projectName
  • BudgetMonth.overlaps.budgetIds
  • BudgetReportChannel.id
  • BudgetReportChannel.name
  • BudgetReportChannel.isPrivate
  • BudgetReports.subscriptions
  • BudgetReportSubscription.slackChannelId
  • BudgetReportSubscription.latestDelivery
  • BudgetTrajectories.budgets
  • BudgetTrajectory.periodWorkdays
  • BudgetTrajectory.consumedSeconds
  • BudgetTrajectory.pacePerWorkdaySeconds
  • BudgetTrajectory.forecastSeconds
  • BudgetTrajectoryDetail.trajectory
  • CapacityAgreement.id
  • CapacityAgreement.note
  • CapacityAgreements.agreements
  • ChangePersonioPersonMapping.expectedAssignment.id
  • ChangePersonioPersonMapping.expectedAssignment.version
  • CollectorKey.id
  • CollectorKey.name
  • CollectorKey.start
  • CollectorKey.requestCount
  • ColorSuggestion.hex
  • ColorSuggestions.suggestions
  • ColorSuggestions.best
  • CompanyMetricMonths.targets.billableRateMin
  • CompanyMetricMonths.targets.billableRateMax
  • CompanyMetricMonths.targets.revenuePerFteMinor
  • CompanyMetrics.catchAllRate.value
  • CompanyMetrics.catchAllRate.previous
  • CompanyMetrics.catchAllRate.trend
  • CompanyMetrics.billableRate.value
  • CompanyMetrics.billableRate.previous
  • CompanyMetrics.billableRate.trend
  • CompanyMetrics.revenuePerFte.value
  • CompanyMetrics.revenuePerFte.previous
  • CompanyMetrics.revenuePerFte.trend
  • CompanyMetrics.targets.billableRateMin
  • CompanyMetrics.targets.billableRateMax
  • CompanyMetrics.targets.revenuePerFteMinor
  • CompanyMetricWeeks.targets.billableRateMin
  • CompanyMetricWeeks.targets.billableRateMax
  • CompanyMetricWeeks.targets.revenuePerFteMinor
  • ConnectHarvest.confirmDifferentEmail
  • CreateAbsence.kind
  • CreateAbsence.note
  • CreateAbsence.externalId
  • CreateCustomer.name
  • CreateCustomer.code
  • CreateCustomer.color
  • CreatedAccessToken.id
  • CreatedAccessToken.name
  • CreatedAccessToken.start
  • CreatedAccessToken.requestCount
  • CreatedAccessToken.token
  • CreatedAllowanceWriteOff.writeOff
  • CreatedAllowanceWriteOff.cut
  • CreateDashboard.name
  • CreateDashboard.layout
  • CreatedCollectorKey.id
  • CreatedCollectorKey.name
  • CreatedCollectorKey.start
  • CreatedCollectorKey.requestCount
  • CreatedCollectorKey.key
  • CreatedProjectAllowanceWriteOff.cut
  • CreatedProjectEntriesWriteOff.cut
  • CreatedProjectWriteOff.entries
  • CreatedProjectWriteOff.allowance
  • CreatedUsageKey.id
  • CreatedUsageKey.name
  • CreatedUsageKey.start
  • CreatedUsageKey.device
  • CreatedWriteOff.writeOff
  • CreatedWriteOff.cut
  • CreateFixedPriceAgreement.name
  • CreateFixedPriceItem.description
  • CreateFixedPriceItem.quantityHundredths
  • CreateFixedPriceItem.unit
  • CreateFixedPriceItem.unitPriceMinor
  • CreateFixedPriceItem.days
  • CreateInviteCodeRequest.label
  • CreateInviteCodeRequest.email
  • CreateInviteCodeRequest.maxUses
  • CreateInvoice.projectIds
  • CreateProject.name
  • CreateProject.rules
  • CreateProject.color
  • CreateProposalInvoice.notes
  • CreateTask.name
  • CreateTimeEntry.notes
  • CreateWorkSchedule.kind
  • CreateWorkSchedule.mondaySeconds
  • CreateWorkSchedule.tuesdaySeconds
  • CreateWorkSchedule.wednesdaySeconds
  • CreateWorkSchedule.thursdaySeconds
  • CreateWorkSchedule.fridaySeconds
  • CreateWorkSchedule.saturdaySeconds
  • CreateWorkSchedule.sundaySeconds
  • CreateWorkSchedule.monthlySeconds
  • CreateWorkSchedule.note
  • Customer.name
  • Customer.code
  • Customer.color
  • Customer.icon
  • CustomerList.data
  • CustomerSetup.customer
  • CustomerSetup.customer.name
  • CustomerSetup.customer.code
  • CustomerSetup.easybill.easybillCustomerId
  • CustomerSetup.project.name
  • CustomerSetupResult.customer
  • CustomerSetupResult.project
  • CustomerSetupResult.billingRule
  • CustomerSetupResult.droppedRateRules
  • CustomerSetupTemplate.tasks.name
  • Dashboard.name
  • Dashboard.layout
  • Dashboards.dashboards
  • DashboardWidgetPerson.name
  • DashboardWidgetPerson.absentSeconds
  • DashboardWidgetPerson.hasAbsence
  • DashboardWidgetPerson.isRunning
  • DashboardWidgetPerson.tasks
  • DashboardWidgetPerson.tasks.taskId
  • DashboardWidgetPerson.tasks.taskName
  • DashboardWidgetPerson.tasks.projectId
  • DashboardWidgetPerson.tasks.projectName
  • DashboardWidgetPerson.tasks.customerId
  • DashboardWidgetPerson.tasks.customerName
  • DashboardWidgetPerson.tasks.trackedSeconds
  • DashboardWidgetPerson.tasks.isRunning
  • DashboardWidgetPlacement.x
  • DashboardWidgetPlacement.y
  • DashboardWidgetPlacement.w
  • DashboardWidgetPlacement.h
  • DashboardWidgetsRequest.requests
  • DeletedTimeEntry.actorName
  • DeletedTimeEntry.notes
  • DeletedTimeEntry.taskName
  • DeletedTimeEntry.projectName
  • DeletedTimeEntry.customerName
  • DeleteTimeEntries.entryIds
  • DemoDataCleanup.deleted
  • DemoDataCleanup.preserved
  • DemoDataCounts.customers
  • DemoDataCounts.projects
  • DemoDataCounts.tasks
  • DemoDataCounts.timeEntries
  • DemoDataStatus.exists
  • DemoDataStatus.createdAt
  • DemoDataStatus.counts
  • DemoDataStatus.canManage
  • Divergence.invoiceStatus
  • Divergence.customerName
  • Divergence.customerIcon
  • Divergence.customerIcon.contentType
  • Divergence.customerIcon.width
  • Divergence.customerIcon.height
  • Divergence.customerIcon.byteSize
  • Divergence.customerIcon.checksum
  • Divergence.projectName
  • Divergence.taskName
  • Divergence.billed.claimedSeconds
  • Divergence.billed.customerName
  • Divergence.current.isBillable
  • Divergence.current.durationSeconds
  • Divergence.current.customerName
  • Divergence.override.reason
  • Divergence.override.actorName
  • DroppedRateRule.taskName
  • DroppedRateRule.personName
  • DroppedRateRule.budgetName
  • DroppedRateRule.hourlyRateMinor
  • EasybillConnection.configured
  • EasybillConnection.easybillServicePositionId
  • EasybillConnection.easybillServiceLabel
  • EasybillConnection.easybillExpensePositionId
  • EasybillConnection.easybillExpenseLabel
  • EasybillCustomerList.data
  • EasybillCustomerList.data.id
  • EasybillCustomerList.data.number
  • EasybillCustomerList.data.companyName
  • EasybillCustomerList.data.firstName
  • EasybillCustomerList.data.lastName
  • EasybillCustomerList.data.zipCode
  • EasybillCustomerList.data.city
  • EasybillCustomerList.page
  • EasybillCustomerList.pages
  • EasybillCustomerMapping.easybillCustomerId
  • EasybillCustomerMapping.easybillCustomerName
  • EasybillCustomerMappingList.data
  • EasybillOutstanding.openMinor
  • EasybillOutstanding.openCount
  • EasybillOutstanding.overdueMinor
  • EasybillOutstanding.overdueCount
  • EasybillOutstanding.withheldCount
  • EasybillServiceList.data
  • EasybillServiceList.data.id
  • EasybillServiceList.data.number
  • EasybillServiceList.data.description
  • EasybillServiceList.data.unit
  • EasybillServiceList.data.vatPercent
  • EasybillServiceList.page
  • EasybillServiceList.pages
  • ExportedInvoice.customer
  • ExportedInvoice.customer.name
  • ExportedInvoice.subtotalMinor
  • ExportedInvoice.notes
  • ExportedInvoice.export
  • ExportedInvoice.transferredByHand
  • ExportedInvoice.document
  • ExportedInvoice.recordedDocument
  • ExportedInvoice.updatedAtPrecise
  • ExportedInvoice.lines
  • ExportedInvoice.projects
  • ExportedInvoice.projects.name
  • ExportedInvoice.budgets
  • ExportedInvoice.budgets.name
  • ExportedInvoice.tasks
  • ExportedInvoice.tasks.name
  • ExportedInvoice.tasks.projectName
  • ExternallyBilledCustomer.customerName
  • ExternallyBilledCustomer.icon
  • ExternallyBilledCustomer.icon.contentType
  • ExternallyBilledCustomer.icon.width
  • ExternallyBilledCustomer.icon.height
  • ExternallyBilledCustomer.icon.byteSize
  • ExternallyBilledCustomer.icon.checksum
  • ExternallyBilledCustomer.archived
  • ExternallyBilledCustomer.projects
  • ExternallyBilledPerson.personName
  • ExternallyBilledPerson.seconds
  • ExternallyBilledProject.projectName
  • ExternallyBilledProject.customerName
  • ExternallyBilledProject.customerArchived
  • ExternallyBilledProject.customerIcon
  • ExternallyBilledProject.customerIcon.contentType
  • ExternallyBilledProject.customerIcon.width
  • ExternallyBilledProject.customerIcon.height
  • ExternallyBilledProject.customerIcon.byteSize
  • ExternallyBilledProject.customerIcon.checksum
  • ExternallyBilledProjectRollup.projectName
  • ExternallyBilledProjectRollup.customerName
  • ExternallyBilledProjectRollup.customerArchived
  • ExternallyBilledRate.seconds
  • ExternallyBilledSummary.total
  • FixedPriceAgreement.name
  • FixedPriceAgreement.tasks.name
  • FixedPriceAgreement.items
  • FixedPriceControlling.hours.totalSeconds
  • FixedPriceControlling.people.totalSeconds
  • FixedPriceControlling.revenue
  • FixedPriceControlling.rates.perAllHours
  • FixedPriceControlling.rates.perFixedPriceHours
  • FixedPriceItem.unit
  • FixedPriceItem.policy
  • FixedPriceRevision.subject
  • FixedPriceRevision.action
  • FixedPriceRevision.reason
  • GoogleCalendarAuthorizeUrl.url
  • GoogleCalendarConnection.configured
  • GoogleCalendarConnection.connection
  • GoogleCalendarConnection.connection.googleAccountEmail
  • GoogleCalendarConnection.connection.calendarIds
  • GoogleCalendarConnection.connection.includeDetails
  • GoogleCalendarList.data
  • GoogleCalendarList.data.id
  • GoogleCalendarList.data.name
  • GoogleCalendarList.data.primary
  • GoogleCalendarList.data.selected
  • GoogleCalendarSync.reported
  • GoogleProvisionResult.outcome
  • GoogleProvisionResult.organizationId
  • HarvestAdoptResult.results
  • HarvestAdoptResult.results.status
  • HarvestAdoptResult.results.harvestTimeEntryId
  • HarvestAdoptResult.results.adopted
  • HarvestAdoptResult.results.reason
  • HarvestAdoptResult.results.detail
  • HarvestAdoptResult.adopted
  • HarvestAdoptResult.unchanged
  • HarvestAdoptResult.failed
  • HarvestCompareFieldDifferenceDetail.field
  • HarvestCompareFieldDifferenceDetail.expected
  • HarvestCompareFieldDifferenceDetail.remote
  • HarvestCompareResult.results
  • HarvestCompareResult.results.status
  • HarvestCompareResult.results.harvestTimeEntryId
  • HarvestCompareResult.results.differences
  • HarvestCompareResult.results.differenceDetails
  • HarvestCompareResult.results.detail
  • HarvestConnection.configured
  • HarvestConnection.connection
  • HarvestConnection.connection.accountId
  • HarvestConnection.connection.harvestUserEmail
  • HarvestConnection.connection.harvestUserName
  • HarvestConnection.connection.harvestUserTimezone
  • HarvestConnection.connection.timestampMode
  • HarvestExportRequest.timeEntryIds
  • HarvestExportResult.results
  • HarvestExportResult.results.status
  • HarvestExportResult.results.harvestTimeEntryId
  • HarvestExportResult.results.reason
  • HarvestExportResult.results.detail
  • HarvestExportResult.created
  • HarvestExportResult.updated
  • HarvestExportResult.failed
  • HarvestImportResult.preview
  • HarvestImportResult.results
  • HarvestImportResult.results.harvestTimeEntryId
  • HarvestImportResult.results.status
  • HarvestImportResult.results.reason
  • HarvestImportResult.results.detail
  • HarvestImportResult.results.harvest
  • HarvestImportResult.imported
  • HarvestImportResult.skipped
  • HarvestPeriodSummary.amounts.currency
  • HarvestPeriodSummary.amounts.billableAmountMinor
  • HarvestProjectLink.harvestProjectId
  • HarvestProjectList.data
  • HarvestProjectList.data.id
  • HarvestProjectList.data.name
  • HarvestProjectMapping.harvestProjectId
  • HarvestProjectTeamImportResult.preview
  • HarvestProjectTeamImportResult.members
  • HarvestProjectTeamImportResult.members.imported
  • HarvestProjectTeamImportResult.members.skipped
  • HarvestProjectTeamImportResult.members.claimsWritten
  • HarvestProjectTeamImportResult.members.truncated
  • HarvestProjectTeamImportResult.members.results
  • HarvestProjectTeamImportResult.imported
  • HarvestProjectTeamImportResult.skipped
  • HarvestProjectTeamImportResult.claimsWritten
  • HarvestProjectTeamImportResult.otherTrackers.harvestUserId
  • HarvestProjectTeamImportResult.otherTrackers.harvestUserName
  • HarvestSecondsRequest.preview
  • HarvestSecondsResult.preview
  • HarvestSecondsResult.results
  • HarvestSecondsResult.results.harvestTimeEntryId
  • HarvestSecondsResult.results.status
  • HarvestSecondsResult.results.reason
  • HarvestSecondsResult.results.detail
  • HarvestSecondsResult.results.fromSeconds
  • HarvestSecondsResult.results.toSeconds
  • HarvestSecondsResult.corrected
  • HarvestSecondsResult.unchanged
  • HarvestSecondsResult.failed
  • HarvestTaskList.data
  • HarvestTaskList.data.id
  • HarvestTaskList.data.name
  • HarvestTaskMapping.harvestProjectId
  • HarvestTaskMapping.harvestTaskId
  • Hint.userId
  • Hint.signal
  • Hint.externalId
  • Hint.clientHintId
  • Hint.path
  • Hint.details
  • Hint.durationSeconds
  • Hint.occurrenceCount
  • Hint.version
  • Hint.isResolved
  • HintEvaluation.suggestions
  • HintPathNode.label
  • HintRule.path
  • HintRuleInput.subject
  • HintRuleInput.path
  • HintSubject.subject
  • HintSubject.path
  • HintSubject.hintCount
  • HintSubject.occurrenceCount
  • HintSubject.observedSeconds
  • HintTaskSuggestion.origin
  • HintTaskSuggestion.acceptedCount
  • HintWithSuggestion.userId
  • HintWithSuggestion.signal
  • HintWithSuggestion.externalId
  • HintWithSuggestion.clientHintId
  • HintWithSuggestion.path
  • HintWithSuggestion.details
  • HintWithSuggestion.durationSeconds
  • HintWithSuggestion.occurrenceCount
  • HintWithSuggestion.version
  • HintWithSuggestion.isResolved
  • HintWithSuggestion.taskSuggestion
  • HintWithSuggestion.classifications
  • HoursOverTimeBucket.totalSeconds
  • ImpersonationSession.sessionToken
  • ImpersonationSession.sessionId
  • ImpersonationSession.expiresAt
  • ImpersonationSession.user
  • ImpersonationSession.user.id
  • ImpersonationSession.user.name
  • ImpersonationSession.user.email
  • ImpersonationSession.user.image
  • InsertTimeEntry.notes
  • InsertTimeEntry.affected.keep
  • InviteCodeRedemption.id
  • InviteCodeRedemption.userEmail
  • InviteCodeRedemption.userName
  • InviteCodeRedemption.organizationId
  • InviteCodeRedemption.organizationName
  • Invoice.customer
  • Invoice.customer.name
  • Invoice.subtotalMinor
  • Invoice.notes
  • Invoice.export
  • Invoice.transferredByHand
  • Invoice.document
  • Invoice.recordedDocument
  • Invoice.updatedAtPrecise
  • InvoiceDocument.provider
  • InvoiceDraft.lines
  • InvoiceDraft.subtotalMinor
  • InvoiceDraft.entryCount
  • InvoiceDraft.durationSeconds
  • InvoiceDraft.excluded
  • InvoiceDraft.projectIds
  • InvoiceDraftLine.position
  • InvoiceDraftLine.description
  • InvoiceDraftLine.amountMinor
  • InvoiceExclusion.durationSeconds
  • InvoiceExclusionTask.projectName
  • InvoiceExclusionTask.taskName
  • InvoiceExclusionTask.entryCount
  • InvoiceExclusionTask.durationSeconds
  • InvoiceExclusionTask.allowanceSeconds
  • InvoiceExport.provider
  • InvoiceExport.documentId
  • InvoiceExport.unclear
  • InvoiceExport.documentLink
  • InvoiceExport.documentLink.url
  • InvoiceExport.documentLink.providerName
  • InvoiceLine.position
  • InvoiceLine.description
  • InvoiceLine.amountMinor
  • InvoiceList.data
  • InvoiceListItem.customer
  • InvoiceListItem.customer.name
  • InvoiceListItem.subtotalMinor
  • InvoiceListItem.notes
  • InvoiceListItem.export
  • InvoiceListItem.transferredByHand
  • InvoiceListItem.document
  • InvoiceListItem.recordedDocument
  • InvoiceListItem.updatedAtPrecise
  • InvoiceListItem.customerIcon
  • InvoiceListItem.customerIcon.contentType
  • InvoiceListItem.customerIcon.width
  • InvoiceListItem.customerIcon.height
  • InvoiceListItem.customerIcon.byteSize
  • InvoiceListItem.customerIcon.checksum
  • InvoiceStatusEntry.customer
  • InvoiceStatusEntry.customer.name
  • InvoiceStatusEntry.export
  • InvoiceStatusExport.provider
  • InvoiceStatusList.data
  • InvoiceWithLines.customer
  • InvoiceWithLines.customer.name
  • InvoiceWithLines.subtotalMinor
  • InvoiceWithLines.notes
  • InvoiceWithLines.export
  • InvoiceWithLines.transferredByHand
  • InvoiceWithLines.document
  • InvoiceWithLines.recordedDocument
  • InvoiceWithLines.updatedAtPrecise
  • InvoiceWithLines.lines
  • InvoiceWithLines.projects
  • InvoiceWithLines.projects.name
  • InvoiceWithLines.budgets
  • InvoiceWithLines.budgets.name
  • InvoiceWithLines.tasks
  • InvoiceWithLines.tasks.name
  • InvoiceWithLines.tasks.projectName
  • IosNotificationPushToken.installationId
  • IosNotificationPushToken.environment
  • IosNotificationPushTokenForget.installationId
  • IosNotificationPushTokenRegistration.installationId
  • IosNotificationPushTokenRegistration.environment
  • IosNotificationPushTokenRegistration.token
  • JoinableProjectList.data
  • JoinableProjectList.data.projectName
  • LiveActivityStartToken.environment
  • LiveActivityStartTokenRegistration.environment
  • LiveActivityStartTokenRegistration.token
  • LiveActivityUpdateToken.activityId
  • LiveActivityUpdateToken.environment
  • LiveActivityUpdateTokenRegistration.activityId
  • LiveActivityUpdateTokenRegistration.environment
  • LiveActivityUpdateTokenRegistration.token
  • McpConsentRequest.clientName
  • McpConsentRequest.redirectHost
  • McpConsentRequest.scopes
  • McpConsentRequest.grants
  • McpConsentRequest.grants.hintsReadEnabled
  • McpConsentRequest.grants.budgetWriteEnabled
  • McpConsentRequest.grants.moneyReadEnabled
  • McpConsentRequest.grants.billingWriteEnabled
  • McpConsentRequest.unavailable
  • McpGrant.organization
  • McpGrant.organization.id
  • McpGrant.organization.name
  • Me.user
  • Me.user.id
  • Me.user.name
  • Me.user.email
  • Me.user.avatarUrl
  • Me.user.image
  • Me.user.avatar
  • Me.person
  • Me.activeOrganizationId
  • Me.preferences
  • Me.organizationSettings
  • Me.effectiveShowRunningTimerAvatars
  • Me.tourAvailable
  • Me.effectiveTourEnabled
  • Me.jamiAvailable
  • Me.effectiveJamiEnabled
  • Me.workSchedules
  • Me.isPlatformOperator
  • Me.isImpersonating
  • MemberAvatars.avatars
  • MemberAvatars.avatars.avatarUrl
  • MemberGoogleAccounts.userIds
  • MergeTimeEntries.entryIds
  • MergeTimeEntries.notes
  • MoveBillingAllocation.reason
  • OrganizationDashboard.revenue
  • OrganizationDashboard.revenue.today
  • OrganizationDashboard.revenue.week
  • OrganizationDashboard.revenue.month
  • OrganizationDashboard.attendanceToday.trackedSeconds
  • OrganizationDashboard.attendanceToday.billableSeconds
  • OrganizationDashboard.customers
  • OrganizationDashboard.customers.customerId
  • OrganizationDashboard.customers.customerName
  • OrganizationDashboard.customers.revenue
  • OrganizationDashboard.people
  • OrganizationDashboard.people.name
  • OrganizationDashboard.people.absentSeconds
  • OrganizationDashboard.people.hasAbsence
  • OrganizationDashboard.people.isRunning
  • OrganizationDashboard.people.tasks
  • OrganizationDomain.domain
  • OrganizationDomain.verificationHost
  • OrganizationDomain.verificationValue
  • OrganizationDomain.autoJoinEnabled
  • OrganizationFeatureSwitches.planning
  • OrganizationFeatureSwitches.customer-portal
  • OrganizationFeatureSwitches.tickets
  • OrganizationFeatureSwitches.absences
  • OrganizationIcon.icon
  • OrganizationLoginPolicy.enabled
  • OrganizationLoginPolicy.lastDisabledReason
  • OrganizationLoginPolicy.domainVerified
  • OrganizationLoginPolicy.viewerHasGoogleAccount
  • OrganizationMembers.data
  • OrganizationMembers.data.name
  • OrganizationMembers.data.email
  • OrganizationRunningTimers.entries
  • OrganizationRunningTimers.entries.task
  • OrganizationRunningTimers.entries.project
  • OrganizationRunningTimers.entries.customer
  • OrganizationSettings.organizationId
  • OrganizationSettings.hoursPerDay
  • OrganizationSettings.timeZone
  • OrganizationSettings.weekStartsOn
  • OtlpLogsRequest.resourceLogs
  • OtlpLogsRequest.resourceLogs.resource
  • OtlpLogsRequest.resourceLogs.scopeLogs
  • PeriodCloseChange.reason
  • PeriodCloseChange.movedByName
  • PeriodCloseResult.projects
  • PeriodCloseResult.projects.moved
  • PersonActivity.totalSeconds
  • PersonActivity.billableSeconds
  • PersonActivity.entryCount
  • PersonActivity.daysWorked
  • PersonActivity.perDay
  • PersonActivity.projects
  • PersonActivityDay.totalSeconds
  • PersonActivityDay.billableSeconds
  • PersonActivityDay.projects
  • PersonActivityDay.tasks
  • PersonActivityDayProject.projectId
  • PersonActivityDayProject.totalSeconds
  • PersonActivityDayProject.billableSeconds
  • PersonActivityDayTask.taskId
  • PersonActivityDayTask.projectId
  • PersonActivityDayTask.totalSeconds
  • PersonActivityDayTask.billableSeconds
  • PersonActivityNote.text
  • PersonActivityNote.entryCount
  • PersonActivityNote.totalSeconds
  • PersonActivityProject.customerId
  • PersonActivityProject.customerName
  • PersonActivityProject.projectId
  • PersonActivityProject.projectName
  • PersonActivityProject.totalSeconds
  • PersonActivityProject.billableSeconds
  • PersonActivityProject.tasks
  • PersonActivityTask.taskId
  • PersonActivityTask.taskName
  • PersonActivityTask.isBillable
  • PersonActivityTask.totalSeconds
  • PersonActivityTask.noteCount
  • PersonActivityTask.notesTruncated
  • PersonActivityTask.notes
  • PersonioPersonMappings.persons
  • PersonioPersonMappings.persons.id
  • PersonioPersonMappings.persons.name
  • PersonioPersonMappings.persons.email
  • PersonioPersonMappings.persons.status
  • PersonioPersonMappings.persons.assignment
  • PersonioPersonMappings.persons.suggestions
  • PersonioPersonMappings.personioPersons
  • PersonioPersonMappings.personioPersons.id
  • PersonioPersonMappings.personioPersons.name
  • PersonioPersonMappings.personioPersons.email
  • PersonioPersonMappings.personioPersons.assignedTo
  • PlanningAllocation.note
  • PlanningContext.targetType
  • PlanningContext.targetName
  • PlanningContext.notes
  • PlanningContextList.contexts
  • PlanningForecast.weeks
  • PlanningForecast.customers
  • PlanningForecast.customers.color
  • PlanningForecast.customers.icon
  • PlanningForecast.customers.name
  • PlanningForecast.projects
  • PlanningForecast.projects.icon
  • PlanningForecast.projects.name
  • PlanningForecast.projects.customerName
  • PlanningForecast.projects.color
  • PlanningForecast.budgets
  • PlanningForecast.budgets.name
  • PlanningForecast.budgets.kind
  • PlanningForecast.budgets.projectIds
  • PlanningForecast.budgets.months
  • PlanningForecast.budgets.customerName
  • PlanningForecast.budgets.total
  • PlanningForecast.allocations
  • PlanningForecast.issues
  • PlanningForecast.issues.kind
  • PlanningForecast.issues.seconds
  • PlanningOverview.people
  • PlanningOverview.people.weeks
  • PlanningOverview.tracked
  • PlanningOverview.customers
  • PlanningOverview.customers.color
  • PlanningOverview.customers.icon
  • PlanningOverview.customers.name
  • PlanningOverview.budgetWeeks.plannedSeconds
  • PlanningOverview.customerWeeks
  • PlanningOverview.customerWeeks.plannedSeconds
  • PlanningOverview.customerWeeks.trackedSeconds
  • PlanningOverview.trackedCustomers
  • PlanningOverview.trackedCustomers.trackedSeconds
  • PlanningOverview.projects.icon
  • PlanningOverview.projects.name
  • PlanningOverview.projects.customerName
  • PlanningOverview.projects.color
  • PlanningOverview.budgets
  • PlanningOverview.budgets.name
  • PlanningOverview.budgets.kind
  • PlanningOverview.budgets.projectIds
  • PlanningOverview.budgets.months
  • PlanningOverviewAllocation.note
  • PlanningOverviewAllocation.unassignedReason
  • PlanningOverviewAllocation.createdByName
  • PlanningOverviewAllocation.updatedByName
  • PlanningReadersAwaitingAbsences.persons
  • PlanningReadersAwaitingAbsences.persons.id
  • PlanningReadersAwaitingAbsences.persons.name
  • PlatformAccessLog.entries
  • PlatformAccessLogEntry.id
  • PlatformAccessLogEntry.actorUserId
  • PlatformAccessLogEntry.actorName
  • PlatformAccessLogEntry.actorEmail
  • PlatformAccessLogEntry.action
  • PlatformAccessLogEntry.organizationId
  • PlatformAccessLogEntry.organizationName
  • PlatformAccessLogEntry.recordedAt
  • PlatformAppInstallation.installationId
  • PlatformAppInstallation.app
  • PlatformAppInstallation.version
  • PlatformAppInstallation.platform
  • PlatformAppInstallation.architecture
  • PlatformAppVersions.activeWindowDays
  • PlatformAppVersions.versions
  • PlatformAppVersions.versions.app
  • PlatformAppVersions.versions.version
  • PlatformAppVersions.versions.outdated
  • PlatformAppVersions.versions.recentInstallations
  • PlatformAppVersions.versions.knownInstallations
  • PlatformAppVersions.versions.users
  • PlatformIosNotificationPushToken.organizationId
  • PlatformIosNotificationPushToken.installationId
  • PlatformIosNotificationPushToken.environment
  • PlatformLiveActivityStartToken.organizationId
  • PlatformLiveActivityStartToken.environment
  • PlatformLiveActivityUpdateToken.organizationId
  • PlatformLiveActivityUpdateToken.activityId
  • PlatformLiveActivityUpdateToken.environment
  • PlatformOrganization.id
  • PlatformOrganization.name
  • PlatformOrganization.slug
  • PlatformOrganization.icon
  • PlatformOrganization.createdAt
  • PlatformOrganization.memberCount
  • PlatformOrganization.lastActivityAt
  • PlatformOrganization.trackedSecondsLast7Days
  • PlatformOrganization.trackedSecondsPrevious7Days
  • PlatformOrganization.activePeopleLast7Days
  • PlatformOrganization.excludedFromAnalytics
  • PlatformOrganizationDetail.id
  • PlatformOrganizationDetail.name
  • PlatformOrganizationDetail.slug
  • PlatformOrganizationDetail.icon
  • PlatformOrganizationDetail.createdAt
  • PlatformOrganizationDetail.memberCount
  • PlatformOrganizationDetail.lastActivityAt
  • PlatformOrganizationDetail.trackedSecondsLast7Days
  • PlatformOrganizationDetail.trackedSecondsPrevious7Days
  • PlatformOrganizationDetail.activePeopleLast7Days
  • PlatformOrganizationDetail.excludedFromAnalytics
  • PlatformOrganizationDetail.members
  • PlatformOrganizationDetail.features
  • PlatformOrganizationDetail.switchedOff
  • PlatformOrganizationMember.status
  • PlatformOrganizationMember.userId
  • PlatformOrganizationMember.name
  • PlatformOrganizationMember.email
  • PlatformOrganizationMember.role
  • PlatformOrganizationMember.lastLoginAt
  • PlatformOrganizationMember.invitedAt
  • PlatformOrganizationMember.invitationExpired
  • PlatformOrganizationMember.appInstallations
  • PlatformOrganizationMember.features
  • PlatformOrganizations.organizations
  • PlatformProductRelease.version
  • PlatformProductRelease.tag
  • PlatformProductRelease.releaseUrl
  • PlatformProductRelease.releaseWorkflow
  • PlatformProductRelease.releaseWorkflow.status
  • PlatformProductRelease.releaseWorkflow.conclusion
  • PlatformProductRelease.releaseWorkflow.htmlUrl
  • PlatformProductRelease.releaseWorkflow.headSha
  • PlatformProductRelease.releaseWorkflow.headCommitMessage
  • PlatformProductRelease.activeReleaseWorkflows
  • PlatformPushLogEntry.level
  • PlatformPushLogEntry.event
  • PlatformPushLogEntry.message
  • PlatformPushLogEntry.outcome
  • PlatformPushLogEntry.pushType
  • PlatformPushLogEntry.tokenKind
  • PlatformPushLogEntry.platform
  • PlatformPushLogEntry.kind
  • PlatformPushLogEntry.activityId
  • PlatformPushLogEntry.reason
  • PlatformPushLogEntry.detail
  • PlatformPushLogs.entries
  • PlatformPushLogs.truncated
  • PlatformPushTokens.iosNotificationTokens
  • PlatformPushTokens.widgetTokens
  • PlatformPushTokens.liveActivityStartToken
  • PlatformPushTokens.liveActivityUpdateTokens
  • PlatformReleases.web
  • PlatformReleases.web.deployWorkflow
  • PlatformReleases.web.activeDeployWorkflows
  • PlatformReleases.web.pendingCiWorkflows
  • PlatformReleases.web.blockingCiWorkflow
  • PlatformReleases.cli
  • PlatformReleases.desktop
  • PlatformReleases.extension
  • PlatformReleases.safariExtension
  • PlatformReleases.ios
  • PlatformReleases.raycast
  • PlatformUsage.windowDays
  • PlatformUsage.areas
  • PlatformUsage.areas.area
  • PlatformUsage.areas.kind
  • PlatformUsage.areas.organizationsUsing
  • PlatformUsage.areas.organizationsEligible
  • PlatformUsage.organizations
  • PlatformUsage.organizations.id
  • PlatformUsage.organizations.name
  • PlatformUsage.organizations.slug
  • PlatformUsage.organizations.icon
  • PlatformUsage.organizations.notInEffect
  • PlatformUsage.organizations.usage
  • PlatformWidgetPushToken.organizationId
  • PlatformWidgetPushToken.platform
  • PlatformWidgetPushToken.kind
  • PlatformWidgetPushToken.environment
  • PlatformWorkflowRun.status
  • PlatformWorkflowRun.conclusion
  • PlatformWorkflowRun.htmlUrl
  • PlatformWorkflowRun.headSha
  • PlatformWorkflowRun.headCommitMessage
  • PreviewInvoice.projectIds
  • PricedPreviewPeriod.dueDay
  • PricingBudget.name
  • Problem.type
  • Problem.title
  • Problem.status
  • Problem.detail
  • Problem.errors.field
  • Problem.errors.message
  • Project.name
  • Project.viewerMayWrite
  • Project.memberCount
  • Project.color
  • Project.icon
  • Project.iconInheritedFrom
  • Project.colorInheritedFrom
  • ProjectAdditionPreview.currency
  • ProjectAdditionPreview.periods
  • ProjectAdditionResult.rule
  • ProjectDetail.name
  • ProjectDetail.viewerMayWrite
  • ProjectDetail.memberCount
  • ProjectDetail.color
  • ProjectDetail.icon
  • ProjectDetail.iconInheritedFrom
  • ProjectDetail.colorInheritedFrom
  • ProjectDetail.unbilled.seconds
  • ProjectDetail.unbilled.amountMinor
  • ProjectDetail.unbilled.unratedSeconds
  • ProjectHours.projectId
  • ProjectHours.projectName
  • ProjectHours.customerId
  • ProjectHours.customerName
  • ProjectHours.totalSeconds
  • ProjectionChange.id
  • ProjectionChange.document
  • ProjectionDocument.entryId
  • ProjectionDocument.seconds
  • ProjectionDocument.personName
  • ProjectionDocument.taskId
  • ProjectionDocument.taskName
  • ProjectionDocument.projectId
  • ProjectionDocument.projectName
  • ProjectionDocument.customerId
  • ProjectionDocument.customerName
  • ProjectionDocument.budgetName
  • ProjectListItem.name
  • ProjectListItem.viewerMayWrite
  • ProjectListItem.memberCount
  • ProjectListItem.color
  • ProjectListItem.icon
  • ProjectListItem.iconInheritedFrom
  • ProjectListItem.colorInheritedFrom
  • ProjectListItem.unbilled.seconds
  • ProjectListItem.unbilled.amountMinor
  • ProjectListItem.unbilled.unratedSeconds
  • ProjectSetup.project.name
  • ProjectSetupResult.project
  • ProjectSetupResult.droppedRateRules
  • ProjectWriteOffCut.entries
  • ProjectWriteOffCut.allowance
  • ProposalInvoicePreview.proposal
  • ProposalInvoicePreview.draft
  • ProposalInvoicePreview.period
  • ProposalPeriod.projects.projectName
  • ProposalPeriod.uncoveredProjects.projectName
  • ProposalPeriod.uncoveredProjects.openSeconds
  • PutRateRules.rules
  • RateExposureMonth.trackedSeconds
  • RateRule.id
  • RateRules.data
  • RateRules.exposureByMonth
  • RateRules.pricingBudgets
  • RateRules.namedBudgets
  • RecentTask.task
  • RecentTask.task.name
  • RecentTask.task.isBillable
  • RecentTask.task.icon
  • RecentTask.project
  • RecentTask.project.name
  • RecentTask.project.color
  • RecentTask.customer
  • RecentTask.customer.name
  • RecentTask.isBillable
  • RecentTask.entryCount
  • RecordedDocumentInput.number
  • RecordedDocumentInput.url
  • RecordedOutstanding.openMinor
  • RecordedOutstanding.openCount
  • RecordedOutstanding.overdueMinor
  • RecordedOutstanding.overdueCount
  • RecordedOutstanding.withheldCount
  • ReleaseExternalSettlement.selector
  • ReportHint.path
  • ReportHint.signal
  • ResourceIcon.contentType
  • ResourceIcon.width
  • ResourceIcon.height
  • ResourceIcon.byteSize
  • ResourceIcon.checksum
  • RetaskTimeEntries.entryIds
  • RetiredPricingBudget.name
  • RevisionExternalReference.provider
  • RevisionExternalReference.key
  • RevisionExternalReference.url
  • RevisionExternalReference.title
  • SearchResult.customers
  • SearchResult.customers.name
  • SearchResult.customers.icon
  • SearchResult.customers.color
  • SearchResult.projects
  • SearchResult.projects.name
  • SearchResult.projects.icon
  • SearchResult.projects.color
  • SearchResult.projects.customer
  • SearchResult.tasks
  • SearchResult.tasks.name
  • SearchResult.tasks.icon
  • SearchResult.tasks.isBillable
  • SearchResult.tasks.project
  • SearchResult.tasks.customer
  • SetEasybillCustomerMapping.easybillCustomerId
  • SetPlanningAllocation.note
  • SetPlanningContext.targetType
  • SevdeskAccount.taxRuleWarning
  • SevdeskAccount.smallSettlement
  • SevdeskConnection.configured
  • SevdeskConnection.servicePartId
  • SevdeskConnection.servicePartLabel
  • SevdeskConnection.expensePartId
  • SevdeskConnection.expensePartLabel
  • SevdeskConnection.contactPersonId
  • SevdeskConnection.contactPersonLabel
  • SevdeskConnectionResult.configured
  • SevdeskConnectionResult.servicePartId
  • SevdeskConnectionResult.servicePartLabel
  • SevdeskConnectionResult.expensePartId
  • SevdeskConnectionResult.expensePartLabel
  • SevdeskConnectionResult.contactPersonId
  • SevdeskConnectionResult.contactPersonLabel
  • SevdeskConnectionResult.taxRuleWarning
  • SevdeskContactList.data
  • SevdeskContactList.data.id
  • SevdeskContactList.data.name
  • SevdeskContactList.data.customerNumber
  • SevdeskContactList.page
  • SevdeskContactList.pages
  • SevdeskContactPersonList.data
  • SevdeskContactPersonList.data.id
  • SevdeskContactPersonList.data.name
  • SevdeskContactPersonList.data.role
  • SevdeskCustomerMapping.sevdeskContactId
  • SevdeskCustomerMapping.sevdeskContactName
  • SevdeskCustomerMappingList.data
  • SevdeskPartList.data
  • SevdeskPartList.data.id
  • SevdeskPartList.data.partNumber
  • SevdeskPartList.data.name
  • SevdeskPartList.data.vatPercent
  • SevdeskPartList.mayHaveMore
  • SlackAuthorizeUrl.url
  • SlackConnection.configured
  • SlackConnection.workspace
  • SlackConnection.workspace.teamId
  • SlackConnection.workspace.teamName
  • SlackConnection.identity
  • SlackConnection.identity.slackUserId
  • SlackConnection.identity.includeMessageText
  • SlackConnection.identity.statusEnabled
  • SlackConnection.identity.statusTemplate
  • SlackConnection.identity.statusEmoji
  • SlackConnection.identity.presenceEnabled
  • SlackConnection.identity.huddleEnabled
  • SplitTimeEntryResult.data
  • StartTimeEntry.notes
  • StartTimeEntry.stopRunning
  • Task.name
  • Task.fixedPriceAgreement
  • Task.color
  • Task.icon
  • Task.iconInheritedFrom
  • Task.colorInheritedFrom
  • TaskFixedPriceAgreement.name
  • TicketDayReportRow.provider
  • TicketDayReportRow.key
  • TicketDayReportRow.url
  • TicketDayReportRow.title
  • TicketDayReportRow.totalSeconds
  • TicketDayReportRow.carriedMinutes
  • TicketDayReportRow.entryCount
  • TicketDayReportRow.personIds
  • TicketReportRow.provider
  • TicketReportRow.key
  • TicketReportRow.url
  • TicketReportRow.title
  • TicketReportRow.totalSeconds
  • TicketReportRow.entryCount
  • TicketReportRow.personIds
  • TimeAllowance.personName
  • TimeAllowance.taskName
  • TimeAllowance.projectName
  • TimeAllowance.customerName
  • TimeAllowance.note
  • TimeAllowance.adjustments.note
  • TimeAllowanceAdjustment.note
  • TimeEntry.notes
  • TimeEntry.durationSeconds
  • TimeEntry.isRunning
  • TimeEntry.isBillable
  • TimeEntry.billableAmountMinor
  • TimeEntry.rateSlices.seconds
  • TimeEntry.rateSlices.amountMinor
  • TimeEntryList.data
  • TimeEntryLogEntry.action
  • TimeEntryLogEntry.actorName
  • TimeEntryLogEntry.notes
  • TimeEntryLogEntry.taskName
  • TimeEntryLogEntry.projectName
  • TimeEntryLogEntry.customerName
  • TimeEntryLogEntry.reason
  • TimeEntryLogEntry.externalReference
  • TimeEntryLogHead.notes
  • TimeEntryLogHead.taskName
  • TimeEntryLogHead.projectName
  • TimeEntryLogHead.customerName
  • TimeEntryRevision.action
  • TimeEntryRevision.position
  • TimeEntryRevision.actorName
  • TimeEntryRevision.notes
  • TimeEntryRevision.taskName
  • TimeEntryRevision.projectName
  • TimeEntryRevision.customerName
  • TimeEntryRevision.reason
  • TimeEntryRevision.externalReference
  • TimeEntryWithContext.notes
  • TimeEntryWithContext.durationSeconds
  • TimeEntryWithContext.isRunning
  • TimeEntryWithContext.isBillable
  • TimeEntryWithContext.billableAmountMinor
  • TimeEntryWithContext.rateSlices.seconds
  • TimeEntryWithContext.rateSlices.amountMinor
  • TimeEntryWithContext.task
  • TimeEntryWithContext.task.name
  • TimeEntryWithContext.task.isBillable
  • TimeEntryWithContext.project
  • TimeEntryWithContext.project.name
  • TimeEntryWithContext.project.color
  • TimeEntryWithContext.project.icon
  • TimeEntryWithContext.project.iconInheritedFrom
  • TimeEntryWithContext.project.colorInheritedFrom
  • TimeEntryWithContext.customer
  • TimeEntryWithContext.customer.name
  • TimeEntryWithContext.customer.color
  • TimeEntryWithContext.customer.icon
  • TimeEntryWithContext.updatedAtPrecise
  • TimeEntryWithContext.harvestExport
  • TimeEntryWithContext.harvestExport.harvestTimeEntryId
  • TimeEntryWithContext.settlement
  • TimeEntryWithContext.policy
  • TimeEntryWithContext.externalReferences
  • TimeProgress.currency
  • TimeProgress.total
  • TimeProgress.throughToday
  • TimeRangeProgress.nonBillableSeconds
  • TimeRangeProgress.entryCount
  • TimerSnapshot.running
  • TimerSnapshot.recentTasks
  • TimerSnapshot.completedTodaySeconds
  • TimerSnapshot.completedBillableTodaySeconds
  • TimerSnapshot.dailyTargetSeconds
  • TimerStopResult.running
  • TimerStopResult.recentTasks
  • TimerStopResult.completedTodaySeconds
  • TimerStopResult.completedBillableTodaySeconds
  • TimerStopResult.dailyTargetSeconds
  • TransferInvoice.recordedDocument
  • UnbilledCustomer.customerName
  • UnbilledCustomer.icon
  • UnbilledCustomer.icon.contentType
  • UnbilledCustomer.icon.width
  • UnbilledCustomer.icon.height
  • UnbilledCustomer.icon.byteSize
  • UnbilledCustomer.icon.checksum
  • UnbilledCustomer.archived
  • UnbilledCustomer.projects
  • UnbilledProject.projectName
  • UnbilledProject.joinableRules.ruleName
  • UnbilledSummary.total
  • UnbilledSummary.customers
  • UpdateAbsence.kind
  • UpdateAbsence.secondsPerDay
  • UpdateAbsence.note
  • UpdateBudget.name
  • UpdateBudget.projectIds
  • UpdateCapacityAgreement.sharePercent
  • UpdateCapacityAgreement.soldSecondsPerWeek
  • UpdateCapacityAgreement.inTeamMetric
  • UpdateCapacityAgreement.note
  • UpdateCustomer.name
  • UpdateCustomer.code
  • UpdateCustomer.color
  • UpdateDashboard.name
  • UpdateDashboard.layout
  • UpdateFixedPriceAgreement.name
  • UpdateFixedPriceItem.description
  • UpdateFixedPriceItem.quantityHundredths
  • UpdateFixedPriceItem.unit
  • UpdateFixedPriceItem.unitPriceMinor
  • UpdateFixedPriceItem.days
  • UpdateFixedPriceItem.people
  • UpdateInvoice.notes
  • UpdateMemberDailyReport.dailyOrgReportEmailEnabled
  • UpdateMemberDailyReport.dailyOrgReportSendWeekdays
  • UpdateMemberDailyReport.dailyOrgReportSendHour
  • UpdateOrganizationFeatureSwitches.planning
  • UpdateOrganizationFeatureSwitches.customer-portal
  • UpdateOrganizationFeatureSwitches.tickets
  • UpdateOrganizationFeatureSwitches.absences
  • UpdateOrganizationSettings.hoursPerDay
  • UpdateOrganizationSettings.timeZone
  • UpdateOrganizationSettings.weekStartsOn
  • UpdateOrganizationSettings.runningTimerAvatarsEnabled
  • UpdateOrganizationSettings.tourEnabled
  • UpdateOrganizationSettings.jamiEnabled
  • UpdateOrganizationSettings.llmHintEvaluationEnabled
  • UpdateProject.name
  • UpdateProject.color
  • UpdateTask.name
  • UpdateTimeEntry.notes
  • UpdateUserPreferences.locale
  • UpdateUserPreferences.timeZone
  • UpdateUserPreferences.llmHintEvaluationConsented
  • UpdateUserPreferences.keyboardShortcutsEnabled
  • UpdateUserPreferences.runningTimerAvatarsEnabled
  • UpdateUserPreferences.tourEnabled
  • UpdateUserPreferences.jamiEnabled
  • UpdateUserPreferences.forgottenTimerEmailEnabled
  • UpdateUserPreferences.forgottenTimerThresholdSeconds
  • UpdateUserPreferences.operatorNewUserEmailEnabled
  • UpdateUserPreferences.operatorNewOrganizationEmailEnabled
  • UpdateUserPreferences.organizationNewMemberEmailEnabled
  • UpdateUserPreferences.dailyOrgReportEmailEnabled
  • UpdateUserPreferences.dailyOrgReportSendWeekdays
  • UpdateUserPreferences.dailyOrgReportSendHour
  • UpdateUserPreferences.budgetAlertEmailEnabled
  • UpdateUserPreferences.hintMaxViewEnabled
  • UpdateUserPreferences.hintCorrectionsEnabled
  • UpdateUserPreferences.hintBoundariesEnabled
  • UpdateUserPreferences.hintMinDeviationSeconds
  • UpdateUserPreferences.hintMinDurationSeconds
  • UpdateUserPreferences.hintConfirmationsEnabled
  • UpdateUserPreferences.hintInboxEnabled
  • UpdateUserPreferences.hintEvidenceLabelsEnabled
  • UpdateUserPreferences.hintExcludedSources
  • UpdateWorkSchedule.kind
  • UpdateWorkSchedule.mondaySeconds
  • UpdateWorkSchedule.tuesdaySeconds
  • UpdateWorkSchedule.wednesdaySeconds
  • UpdateWorkSchedule.thursdaySeconds
  • UpdateWorkSchedule.fridaySeconds
  • UpdateWorkSchedule.saturdaySeconds
  • UpdateWorkSchedule.sundaySeconds
  • UpdateWorkSchedule.monthlySeconds
  • UpdateWorkSchedule.note
  • UpsertAbsence.kind
  • UsageKey.id
  • UsageKey.name
  • UsageKey.start
  • UsageKey.device
  • UserHours.totalSeconds
  • UserPreferences.locale
  • UserPreferences.timeZone
  • UserProjectCount.projectCount
  • UserSession.id
  • UserSession.current
  • UserSession.ipAddress
  • UserSession.userAgent
  • UserSession.clientType
  • VisualProvenance.kind
  • VisualProvenance.name
  • WatchCredential.token
  • WidgetPushToken.platform
  • WidgetPushToken.kind
  • WidgetPushToken.environment
  • WidgetPushTokenRegistration.platform
  • WidgetPushTokenRegistration.kind
  • WidgetPushTokenRegistration.environment
  • WidgetPushTokenRegistration.token
  • WorkSchedule.mondaySeconds
  • WorkSchedule.tuesdaySeconds
  • WorkSchedule.wednesdaySeconds
  • WorkSchedule.thursdaySeconds
  • WorkSchedule.fridaySeconds
  • WorkSchedule.saturdaySeconds
  • WorkSchedule.sundaySeconds
  • WorkSchedule.note
  • WriteOff.createdByName
  • WriteOff.seconds
  • WriteOff.amountMinor
  • WriteOff.entryCount
  • WriteOff.claimCount
  • WriteOffClaim.personName
  • WriteOffClaim.customerName
  • WriteOffClaim.projectName
  • WriteOffClaim.taskName
  • WriteOffCut.rates
  • WriteOffCut.claims
  • WriteOffList.data
  • WriteOffRateTotal.rateMinor
  • WriteOffRateTotal.seconds
  • WriteOffRateTotal.claimCount
  • WritePolicy.blockers
  • PUT /api/v1/organization/domain body.domain
  • PATCH /api/v1/organization/domain body.autoJoinEnabled
  • PATCH /api/v1/organization/login-policy body.enabled
  • POST /api/v1/customers/{id}/archive body.archived
  • GET /api/v1/projects 200.data
  • GET /api/v1/projects 200.nextCursor
  • POST /api/v1/projects/{id}/archive body.archived
  • GET /api/v1/projects/{id}/members 200.data
  • GET /api/v1/projects/{id}/period-changes 200.data
  • GET /api/v1/tasks 200.data
  • POST /api/v1/tasks/{id}/archive body.archived
  • GET /api/v1/tasks/bookable 200.data
  • GET /api/v1/tasks/recent 200.data
  • GET /api/v1/tasks/suggested-names 200.data
  • GET /api/v1/time-entries/{id}/references 200.data
  • POST /api/v1/time-entries/{id}/references 202.attached
  • GET /api/v1/time-entries/{id}/revisions 200.data
  • GET /api/v1/time-entries/deleted 200.data
  • GET /api/v1/time-entries/last-stopped 200.data
  • GET /api/v1/time-entries/policy 200.data
  • GET /api/v1/time-entries/recent-notes 200.data
  • GET /api/v1/time-entries/revisions 200.data
  • GET /api/v1/time-entries/revisions 200.heads
  • GET /api/v1/time-entries/revisions 200.nextCursor
  • GET /api/v1/time-entries/running 200.data
  • GET /api/v1/time-entries/summary 200.totalSeconds
  • GET /api/v1/invoices/allowance-divergences 200.data
  • GET /api/v1/invoices/allowance-divergences 200.scanned
  • GET /api/v1/invoices/allowance-divergences 200.truncated
  • GET /api/v1/invoices/divergences 200.data
  • GET /api/v1/invoices/divergences 200.nextCursor
  • GET /api/v1/invoices/divergences 200.scanned
  • GET /api/v1/invoices/divergences 200.totalClaims
  • GET /api/v1/reports/hours-by-project 200.data
  • GET /api/v1/reports/hours-by-project 200.totalSeconds
  • GET /api/v1/reports/hours-by-user 200.data
  • GET /api/v1/reports/projection 200.data
  • GET /api/v1/reports/projection 200.nextCursor
  • GET /api/v1/reports/projection/changes 200.data
  • GET /api/v1/reports/projection/changes 200.nextCursor
  • GET /api/v1/reports/projection/changes 200.fullResyncRecommended
  • GET /api/v1/reports/projects-by-user 200.data
  • GET /api/v1/reports/tickets 200.rows
  • GET /api/v1/reports/tickets 200.truncated
  • GET /api/v1/reports/tickets/days 200.rows
  • GET /api/v1/reports/tickets/days 200.truncated
  • GET /api/v1/hint-rules 200.data
  • GET /api/v1/hints 200.data
  • GET /api/v1/hints 200.nextCursor
  • GET /api/v1/hints/sources 200.data
  • GET /api/v1/hints/subjects 200.data
  • GET /api/v1/collectors/keys 200.data
  • GET /api/v1/collectors/usage-keys 200.data
  • GET /api/v1/access-tokens 200.data
  • POST /api/v1/mcp/consent body.oauthQuery
  • POST /api/v1/mcp/consent body.accept
  • POST /api/v1/mcp/consent body.organizationId
  • POST /api/v1/mcp/consent body.scopes
  • POST /api/v1/mcp/consent body.hintsReadEnabled
  • POST /api/v1/mcp/consent body.budgetWriteEnabled
  • POST /api/v1/mcp/consent body.moneyReadEnabled
  • POST /api/v1/mcp/consent body.billingWriteEnabled
  • GET /api/v1/mcp/grants 200.data
  • GET /api/v1/absence-subjects 200.data
  • GET /api/v1/absences/{id}/revisions 200.data
  • GET /api/v1/absences/revisions 200.data
  • GET /api/v1/absences/revisions 200.heads
  • GET /api/v1/absences/revisions 200.nextCursor
  • DELETE /api/v1/admin/organizations/{id} body.proof
  • DELETE /api/v1/admin/organizations/{id} body.proof.challengeId
  • DELETE /api/v1/admin/organizations/{id} body.proof.response
  • DELETE /api/v1/admin/organizations/{id} body.confirmation
  • DELETE /api/v1/admin/organizations/{id}/users/{userId} body.proof
  • DELETE /api/v1/admin/organizations/{id}/users/{userId} body.proof.challengeId
  • DELETE /api/v1/admin/organizations/{id}/users/{userId} body.proof.response
  • DELETE /api/v1/admin/organizations/{id}/users/{userId} body.confirmation
  • POST /api/v1/admin/users/{userId}/impersonation-sessions body.proof
  • POST /api/v1/admin/users/{userId}/impersonation-sessions body.proof.challengeId
  • POST /api/v1/admin/users/{userId}/impersonation-sessions body.proof.response
  • POST /api/v1/app-errors body.errorId
  • POST /api/v1/app-errors body.source
  • POST /api/v1/app-errors body.kind
  • POST /api/v1/app-errors body.operation
  • POST /api/v1/app-errors body.frames
  • POST /api/v1/app-errors body.build
  • POST /api/v1/app-errors 200.errorId
  • POST /api/v1/app-errors 200.requestId
  • POST /api/v1/client-errors body.errorId
  • POST /api/v1/client-errors body.kind
  • POST /api/v1/client-errors body.name
  • POST /api/v1/client-errors body.frames
  • POST /api/v1/client-errors body.route
  • POST /api/v1/client-errors body.build
  • POST /api/v1/client-errors 200.errorId
  • POST /api/v1/client-errors 200.requestId
  • GET /api/v1/billing-proposals 200.data
  • POST /api/v1/billing-proposals/{id}/invoices 201.id
  • GET /api/v1/billing-proposals/{id}/services 200.data
  • GET /api/v1/billing-rules 200.data
  • GET /api/v1/billing-rules/preview-history 200.data
  • GET /api/v1/billing-rules/preview-planned 200.data
  • GET /api/v1/break-dismissals 200.data
  • GET /api/v1/break-periods 200.data
  • GET /api/v1/budgets 200.data
  • POST /api/v1/budgets/{id}/archive body.archived
  • GET /api/v1/budgets/alerts 200.data
  • GET /api/v1/budgets/consumption 200.data
  • GET /api/v1/budgets/report-channels 200.data
  • GET /api/v1/changelog/{kind} 200.kind
  • GET /api/v1/changelog/{kind} 200.entries
  • GET /api/v1/changelog/{kind} 200.entries.version
  • GET /api/v1/changelog/{kind} 200.entries.changes
  • GET /api/v1/changelog/{kind} 200.unreleased
  • GET /api/v1/changelog/{kind} 200.unreleased.type
  • GET /api/v1/changelog/{kind} 200.unreleased.headline
  • GET /api/v1/changelog/{kind} 200.unreleased.note
  • GET /api/v1/changelog/{kind}/status 200.kind
  • GET /api/v1/changelog/{kind}/status 200.hasUnreleased
  • GET /api/v1/customer-portal/{organizationSlug} 200.customer
  • GET /api/v1/customer-portal/{organizationSlug} 200.customer.name
  • GET /api/v1/customer-portal/{organizationSlug} 200.customer.color
  • GET /api/v1/customer-portal/{organizationSlug} 200.projects
  • GET /api/v1/customer-portal/{organizationSlug} 200.projects.name
  • GET /api/v1/customer-portal/{organizationSlug} 200.projects.color
  • GET /api/v1/customer-portal/{organizationSlug}/branding 200.organizationSlug
  • GET /api/v1/customer-portal/{organizationSlug}/branding 200.displayName
  • GET /api/v1/customer-portal/{organizationSlug}/branding 200.accentColor
  • GET /api/v1/customer-portal/{organizationSlug}/branding 200.welcomeMessage
  • GET /api/v1/customer-portal/{organizationSlug}/branding 200.logoUrl
  • GET /api/v1/customer-portal/{organizationSlug}/budgets 200.months
  • GET /api/v1/customer-portal/{organizationSlug}/budgets 200.months.month
  • GET /api/v1/customer-portal/{organizationSlug}/budgets 200.months.budgets
  • GET /api/v1/organization/customer-portal 200.branding
  • GET /api/v1/organization/customer-portal 200.branding.displayName
  • GET /api/v1/organization/customer-portal 200.branding.accentColor
  • GET /api/v1/organization/customer-portal 200.branding.welcomeMessage
  • GET /api/v1/organization/customer-portal 200.projectAccess
  • GET /api/v1/organization/customer-portal 200.projectAccess.mode
  • GET /api/v1/organization/customer-portal 200.projectAccess.selectedProjectIds
  • GET /api/v1/organization/customer-portal 200.projectAccess.availableProjects
  • GET /api/v1/organization/customer-portal 200.budgetAccess
  • GET /api/v1/organization/customer-portal 200.budgetAccess.selectedBudgetIds
  • GET /api/v1/organization/customer-portal 200.budgetAccess.availableBudgets
  • GET /api/v1/organization/customer-portal 200.grants
  • GET /api/v1/organization/customer-portal 200.grants.customerName
  • GET /api/v1/organization/customer-portal 200.grants.email
  • GET /api/v1/organization/customer-portal 200.grants.acceptedAt
  • GET /api/v1/organization/customer-portal 200.grants.revokedAt
  • GET /api/v1/organization/customer-portal 200.grants.createdAt
  • PUT /api/v1/organization/customer-portal/branding body.displayName
  • PUT /api/v1/organization/customer-portal/branding body.accentColor
  • PUT /api/v1/organization/customer-portal/branding body.welcomeMessage
  • PUT /api/v1/organization/customer-portal/branding 200.displayName
  • PUT /api/v1/organization/customer-portal/branding 200.accentColor
  • PUT /api/v1/organization/customer-portal/branding 200.welcomeMessage
  • PUT /api/v1/organization/customer-portal/budgets body.budgetIds
  • PUT /api/v1/organization/customer-portal/budgets 200.selectedBudgetIds
  • POST /api/v1/organization/customer-portal/invitations body.email
  • POST /api/v1/organization/customer-portal/invitations 200.status
  • PUT /api/v1/organization/customer-portal/projects 200.mode
  • PUT /api/v1/organization/customer-portal/projects 200.selectedProjectIds
  • GET /api/v1/downloads 200.downloads
  • GET /api/v1/downloads 200.downloads.kind
  • GET /api/v1/downloads 200.downloads.version
  • GET /api/v1/downloads 200.downloads.platform
  • GET /api/v1/downloads 200.downloads.byteSize
  • GET /api/v1/downloads 200.downloads.downloadUrl
  • GET /api/v1/downloads 200.downloads.fileName
  • POST /api/v1/downloads/cli-native/install-tickets 201.installUrl
  • POST /api/v1/downloads/cli-native/install-tickets 201.powershellUrl
  • POST /api/v1/downloads/cli-native/install-tickets 201.expiresAt
  • POST /api/v1/downloads/cli/install-tickets 201.installUrl
  • POST /api/v1/downloads/cli/install-tickets 201.expiresAt
  • GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion} 200.version
  • GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion} 200.notes
  • GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion} 200.url
  • GET /api/v1/downloads/desktop/updater/{target}/{arch}/{currentVersion} 200.signature
  • GET /api/v1/downloads/firefox-extension/updates.json 200.addons
  • GET /api/v1/fixed-price-agreements 200.data
  • GET /api/v1/fixed-price-agreements/{id}/revisions 200.data
  • GET /api/v1/fixed-price-items 200.data
  • GET /api/v1/fixed-price-items/revisions 200.data
  • POST /api/v1/imports/harvest body.accountId
  • POST /api/v1/imports/harvest body.accessToken
  • POST /api/v1/imports/harvest 200.customers
  • POST /api/v1/imports/harvest 200.customers.created
  • POST /api/v1/imports/harvest 200.customers.updated
  • POST /api/v1/imports/harvest 200.customers.archived
  • POST /api/v1/imports/harvest 200.projects
  • POST /api/v1/imports/harvest 200.projects.created
  • POST /api/v1/imports/harvest 200.projects.updated
  • POST /api/v1/imports/harvest 200.projects.archived
  • POST /api/v1/imports/harvest 200.projects.skipped
  • POST /api/v1/imports/harvest 200.tasks
  • POST /api/v1/imports/harvest 200.tasks.created
  • POST /api/v1/imports/harvest 200.tasks.updated
  • POST /api/v1/imports/harvest 200.tasks.archived
  • POST /api/v1/imports/harvest 200.links
  • POST /api/v1/imports/harvest 200.links.customers
  • POST /api/v1/imports/harvest 200.links.projects
  • POST /api/v1/imports/harvest 200.links.tasks
  • POST /api/v1/imports/harvest 200.members
  • POST /api/v1/imports/harvest 200.members.assigned
  • POST /api/v1/imports/harvest 200.members.removed
  • POST /api/v1/imports/harvest 200.members.unmatched
  • POST /api/v1/imports/harvest 200.unmatchedEmails
  • POST /api/v1/imports/harvest/users body.accountId
  • POST /api/v1/imports/harvest/users body.accessToken
  • POST /api/v1/imports/harvest/users body.preview
  • POST /api/v1/imports/harvest/users body.harvestUserIds
  • POST /api/v1/imports/harvest/users 200.preview
  • POST /api/v1/imports/harvest/users 200.users
  • POST /api/v1/imports/harvest/users 200.users.harvestUserId
  • POST /api/v1/imports/harvest/users 200.users.email
  • POST /api/v1/imports/harvest/users 200.users.name
  • POST /api/v1/imports/harvest/users 200.users.active
  • POST /api/v1/imports/harvest/users 200.users.status
  • POST /api/v1/imports/harvest/users 200.created
  • POST /api/v1/imports/harvest/users 200.withoutHarvestAccount
  • POST /api/v1/imports/harvest/users 200.withoutHarvestAccount.name
  • POST /api/v1/imports/harvest/users 200.withoutHarvestAccount.email
  • GET /api/v1/operator-security 200.ready
  • GET /api/v1/operator-security 200.passkeys
  • GET /api/v1/operator-security 200.passkeys.id
  • GET /api/v1/operator-security 200.passkeys.name
  • GET /api/v1/operator-security 200.passkeys.approved
  • GET /api/v1/operator-security 200.passkeys.createdAt
  • POST /api/v1/operator-security/authentication-options 200.challengeId
  • POST /api/v1/operator-security/authentication-options 200.options
  • POST /api/v1/operator-security/authentication-options 200.options.challenge
  • POST /api/v1/operator-security/authentication-options 200.options.rpId
  • POST /api/v1/operator-security/authentication-options 200.options.timeout
  • POST /api/v1/operator-security/authentication-options 200.options.userVerification
  • POST /api/v1/operator-security/authentication-options 200.options.allowCredentials
  • POST /api/v1/operator-security/passkeys body.name
  • POST /api/v1/operator-security/passkeys body.challengeId
  • POST /api/v1/operator-security/passkeys body.response
  • POST /api/v1/operator-security/passkeys body.response.id
  • POST /api/v1/operator-security/passkeys body.response.rawId
  • POST /api/v1/operator-security/passkeys body.response.type
  • POST /api/v1/operator-security/passkeys body.response.clientExtensionResults
  • POST /api/v1/operator-security/passkeys body.response.authenticatorAttachment
  • POST /api/v1/operator-security/passkeys body.response.response
  • DELETE /api/v1/operator-security/passkeys/{id} body.challengeId
  • DELETE /api/v1/operator-security/passkeys/{id} body.response
  • DELETE /api/v1/operator-security/passkeys/{id} body.response.id
  • DELETE /api/v1/operator-security/passkeys/{id} body.response.rawId
  • DELETE /api/v1/operator-security/passkeys/{id} body.response.type
  • DELETE /api/v1/operator-security/passkeys/{id} body.response.clientExtensionResults
  • DELETE /api/v1/operator-security/passkeys/{id} body.response.authenticatorAttachment
  • DELETE /api/v1/operator-security/passkeys/{id} body.response.response
  • POST /api/v1/operator-security/registration-options body.name
  • POST /api/v1/operator-security/registration-options body.proof
  • POST /api/v1/operator-security/registration-options body.proof.challengeId
  • POST /api/v1/operator-security/registration-options body.proof.response
  • POST /api/v1/operator-security/registration-options 200.challengeId
  • POST /api/v1/operator-security/registration-options 200.options
  • POST /api/v1/operator-security/registration-options 200.options.challenge
  • POST /api/v1/operator-security/registration-options 200.options.rp
  • POST /api/v1/operator-security/registration-options 200.options.user
  • POST /api/v1/operator-security/registration-options 200.options.pubKeyCredParams
  • POST /api/v1/operator-security/registration-options 200.options.timeout
  • POST /api/v1/operator-security/registration-options 200.options.attestation
  • POST /api/v1/operator-security/registration-options 200.options.authenticatorSelection
  • POST /api/v1/operator-security/registration-options 200.options.excludeCredentials
  • GET /api/v1/persons 200.data
  • PUT /api/v1/planning/allocations 200.allocation
  • POST /api/v1/planning/allocations/refine 200.sourceRemainingSeconds
  • POST /api/v1/planning/allocations/refine 200.allocation
  • PUT /api/v1/planning/allocations/remaining 200.allocation
  • GET /api/v1/time-allowances 200.data
  • GET /api/v1/work-schedules 200.data

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.

sessionaccess tokenOAuth

Answers

200 Me The current user and settings.
Problems: 401 — RFC 9457, see Errors.
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).

session only

Answers

200 AssistantTours The tours this account has progress in.
Problems: 401 403 — RFC 9457, see Errors.
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.

session only

Path parameters

tour* "onboarding" | "menu-tabs" 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. onboarding

Request body — application/json

PutAssistantTourProgress

Answers

200 AssistantTourProgress The recorded progress.
Problems: 401 403 422 — RFC 9457, see Errors.
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).

session only

Request body — application/json

contentType* "image/webp" | "image/png" Must match what the bytes actually are; a mismatch is rejected. image/webp
data* string Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. UklGRtQCAABXRUJQVlA4WAoAAAAQAAAA/wAA/wAA...

Answers

200 ResourceIcon The new avatar.
Problems: 401 422 — RFC 9457, see Errors.
delete /api/v1/me/avatar #

Remove your own avatar

Idempotent: having no avatar already still answers 204.

session only

Answers

204 no body No avatar is set.
Problems: 401 — RFC 9457, see Errors.
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.

session only

Answers

200 GoogleProvisionResult The provisioning outcome.
Problems: 401 — RFC 9457, see Errors.
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.

session only

Answers

204 no body The current support session was revoked.
Problems: 401 403 — RFC 9457, see Errors.
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.

session only

Request body — application/json

code* string An invite code, in any casing or spacing — normalized server-side. TIMJ-AAAA-AAAA

Answers

204 no body The code was redeemed.
Problems: 401 409 422 — RFC 9457, see Errors.
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).

session only

Request body — application/json

rateDisplay? "hourly" | "daily" | null | null How rates are presented in the UI. Does not affect stored data. hourly
locale? string
timeZone? string | null
llmHintEvaluationConsented? boolean
keyboardShortcutsEnabled? boolean
runningTimerAvatarsEnabled? boolean
tourEnabled? boolean
jamiEnabled? boolean
forgottenTimerEmailEnabled? boolean
forgottenTimerThresholdSeconds? integer
operatorNewUserEmailEnabled? boolean
operatorNewOrganizationEmailEnabled? boolean
organizationNewMemberEmailEnabled? boolean
dailyOrgReportEmailEnabled? boolean
dailyOrgReportSendWeekdays? integer[]
dailyOrgReportSendHour? integer
budgetAlertEmailEnabled? boolean
dayTab? "list" | "calendar" | "max" „Heute“’s tabs: list („Liste“), calendar („Kalender“) and max („Max 2.0“). calendar
hintMaxViewEnabled? boolean
hintCorrectionsEnabled? boolean
hintBoundariesEnabled? boolean
hintMinDeviationSeconds? integer
hintMinDurationSeconds? integer
hintConfirmationsEnabled? boolean
hintInboxEnabled? boolean
hintEvidenceLabelsEnabled? boolean
hintExcludedSources? string[]

Answers

200 UserPreferences The updated preferences.
Problems: 401 403 422 — RFC 9457, see Errors.
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.

session only

Answers

200 UserSessions Every active session owned by the caller.
Problems: 401 403 — RFC 9457, see Errors.
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.

session only

Path parameters

sessionId* string

Answers

204 no body The session was revoked.
Problems: 401 403 404 — RFC 9457, see Errors.
post /api/v1/me/watch-credential #

Replace the current user’s Apple Watch credential

session only

Answers

201 WatchCredential The credential, shown once.
Problems: 400 401 403 422 — RFC 9457, see Errors.
delete /api/v1/me/watch-credential #

Revoke Apple Watch credentials

sessionaccess tokenOAuth

Answers

204 no body The credential or credentials were revoked.
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

carries its own credential

Path parameters

token* string Opaque signed token from organization.icon.url.

Answers

200 string (binary) The image. image/webp, image/png
304 no body Not modified — the client already has these bytes.
Problems: 404 — RFC 9457, see Errors.
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.

session only

Request body — application/json

delayDays* integer Calendar days after a proposal’s due date before the global recipient joins the rule’s own responsible people, and before weekly repeats begin. 7
recipientPersonId* string (uuid) | null The second-level recipient every escalated or weekly-repeated proposal email also reaches. null leaves the obligation visibly unset rather than silently dropped. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 OrganizationSettings The updated settings.
Problems: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/organization/demo-data #

Get demo-data status for the active organization

session only

Answers

200 DemoDataStatus The current demo-data status.
Problems: 400 401 403 422 — RFC 9457, see Errors.
post /api/v1/organization/demo-data #

Create demo data in the active organization

Creates one tagged, removable set. Requires the owner or admin role.

session only

Answers

201 DemoDataStatus The generated demo-data status.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Answers

200 DemoDataCleanup What was deleted and preserved.
Problems: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/organization/domain #

Google Workspace domain of the active organization

session only

Answers

200 OrganizationDomain The domain claim.
Problems: 400 401 403 422 — RFC 9457, see Errors.
put /api/v1/organization/domain #

Claim a Google Workspace domain

session only

Request body — application/json

domain* string

Answers

200 OrganizationDomain The unverified claim.
Problems: 400 401 403 422 — RFC 9457, see Errors.
patch /api/v1/organization/domain #

Enable or disable automatic Google membership

session only

Request body — application/json

autoJoinEnabled* boolean

Answers

200 OrganizationDomain The updated claim.
Problems: 400 401 403 422 — RFC 9457, see Errors.
delete /api/v1/organization/domain #

Remove the organization domain claim

session only

Answers

204 no body The claim is absent.
Problems: 400 401 403 422 — RFC 9457, see Errors.
post /api/v1/organization/domain/verify #

Verify the DNS challenge now

session only

Answers

200 OrganizationDomain The checked claim.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Answers

200 OrganizationFeatureSwitches The switches.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

planning? boolean
customer-portal? boolean
tickets? boolean
absences? boolean
planningReadConsent? "ask-again" | "extend" 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

200 OrganizationFeatureSwitches The switches after the change.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Answers

200 PlanningReadersAwaitingAbsences The persons concerned.
Problems: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/organization/icon #

Get the active organization's icon

Readable by any member; only an owner or admin may change it.

thymian:readsessionaccess tokenOAuth

Answers

200 OrganizationIcon The organization's icon, or null.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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).

thymian:organization.writesessionaccess tokenno OAuth

Request body — application/json

contentType* "image/webp" | "image/png" Must match what the bytes actually are; a mismatch is rejected. image/webp
data* string Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. UklGRtQCAABXRUJQVlA4WAoAAAAQAAAA/wAA/wAA...

Answers

200 ResourceIcon The new icon.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:organization.writesessionaccess tokenno OAuth

Answers

204 no body No icon is set.
Problems: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/organization/login-policy #

Whether the active organization requires Google-only sign-in

session only

Answers

200 OrganizationLoginPolicy The current policy.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

enabled* boolean

Answers

200 OrganizationLoginPolicy The updated policy.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Answers

200 MemberAvatars Avatars of the active organization’s members.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Answers

200 MemberGoogleAccounts User ids of the active organization’s members with a linked Google account.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Answers

200 OrganizationMembers The members of the active organization.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Path parameters

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 MemberDailyReport The daily report settings.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

dailyOrgReportEmailEnabled? boolean
dailyOrgReportSendWeekdays? integer[]
dailyOrgReportSendHour? integer

Answers

200 MemberDailyReport The updated daily report settings.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Answers

200 OrganizationRunningTimers Presence of everyone currently running a timer.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Answers

200 OrganizationSettings The settings.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:organization.writesessionaccess tokenno OAuth

Request body — application/json

currency? string ISO-4217 currency code. EUR
rateDisplay? "hourly" | "daily" How rates are presented in the UI. Does not affect stored data. hourly
hoursPerDay? string
timeZone? string
weekStartsOn? integer
hintRetentionDays? integer Days a hint is kept before POST /hints prunes it. Enforced when a collector reports, not by a scheduler. 30
runningTimerAvatarsEnabled? boolean
tourEnabled? boolean
jamiEnabled? boolean
llmHintEvaluationEnabled? boolean

Answers

200 OrganizationSettings The updated settings.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

trackingOpenMonths* integer | null null removes the limit; the setting is off by default. 2

Answers

200 OrganizationSettings The updated settings.
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Query parameters

includeArchived? string
search? string Case-insensitive substring match against the customer's name, code or aliases.
limit? integer
cursor? string Keyset cursor: pass the previous page's nextCursor back unchanged.

Answers

200 CustomerList The customers.
Problems: 400 401 403 422 — RFC 9457, see Errors.
post /api/v1/customers #

Create a customer

thymian:customers.writesessionaccess tokenOAuth

Request body — application/json

name* string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
code? string | null
color? string #228be6

Answers

201 Customer The created customer.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
get /api/v1/customers/{id} #

Get a customer

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 Customer The customer.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
patch /api/v1/customers/{id} #

Update a customer

thymian:customers.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

name? string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
code? string | null
color? string #228be6

Answers

200 Customer The updated customer.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

archived* boolean

Answers

200 Customer The customer.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

thymian:customers.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

contentType* "image/webp" | "image/png" Must match what the bytes actually are; a mismatch is rejected. image/webp
data* string Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. UklGRtQCAABXRUJQVlA4WAoAAAAQAAAA/wAA/wAA...

Answers

200 Customer The customer, with its new icon.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:customers.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The customer has no icon.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

closedThroughDay* string | null 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. 2026-08-14
reason? string 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)[] 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

200 PeriodCloseResult What moved, per project.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 ColorSuggestions The ranked suggestions.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

customer* object
customer.name* string
customer.aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
customer.code? string | null
easybill? object 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 4711
project? object The first project.
project.name* string
project.aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
project.visibility? "public" | "private" Who may see the project. public is the whole organization.
project.hourlyRateMinor? integer The project’s default hourly rate in minor units, as one rule from the beginning. Not together with rateTemplateProjectId. 9900
project.rateTemplateProjectId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project.tasks? object[] The tasks to create. No two may share a name, compared case-insensitively.
project.personIds? string (uuid)[] The people to assign. You are assigned anyway, named here or not.
billing? object 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 A human-readable name for the rule.
billing.recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
billing.dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
billing.selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
billing.firstPeriodStart? string 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. 2026-08
billing.responsiblePersonIds? string (uuid)[] The people reminded when a proposal is due. Defaults to you.

Answers

201 CustomerSetupResult What was created.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 CustomerSetupTemplate What the project fills in.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:customers.writesessionaccess tokenOAuth

Request body — application/json

customerIds* string (uuid)[]

Answers

200 BatchSuggestColorsResult What happened, per customer.
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Query parameters

customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerIds? string 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
includeUnbilled? string 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"
unassigned? string Only projects with no members at all — invariant 51’s "unbesetzt".
mine? string Only projects you are a member of.
search? string Case-insensitive substring match against the project’s own name/aliases or its customer’s name/aliases.
orderBy? "activity" | "name" | "customer"
limit? integer
cursor? string Keyset cursor: pass the previous page's nextCursor back unchanged.

Answers

200 object The projects. { data: ProjectListItem[], nextCursor: string | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Request body — application/json

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
rules? WriteRateRule[]
visibility? "public" | "private" Who may see the project. public is the whole organization.
color? string | null #228be6

Answers

201 Project The created project.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Query parameters

includeUnbilled? string 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

200 ProjectDetail The project.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

name? string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
visibility? "public" | "private" Who may see the project. public is the whole organization.
color? string | null #228be6

Answers

200 Project The updated project.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
post /api/v1/projects/{id}/archive #

Archive or restore a project

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

archived* boolean

Answers

200 Project The project.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:project-icons.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

contentType* "image/webp" | "image/png" Must match what the bytes actually are; a mismatch is rejected. image/webp
data* string Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. UklGRtQCAABXRUJQVlA4WAoAAAAQAAAA/wAA/wAA...

Answers

200 Project The project, with its new icon.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

thymian:project-icons.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The project has no icon.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The project members. { data: ProjectMember[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 ProjectMember The project member.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
delete /api/v1/projects/{id}/members/{personId} #

Remove a person from a project

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The assignment was removed.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

closedThroughDay* string | null 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. 2026-08-14
reason? string 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

200 PeriodCloseResult What moved.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The movements, newest first. { data: PeriodCloseChange[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 RateRules The project’s rules.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

Answers

200 RateRules The project’s rules, after the change.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

from? string Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. 2026-08-14
until? string Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. 2026-08-14
reason* string Why these hours and months will never be billed. Required, for the same reason CreateWriteOff.reason is. Projekt nach Vorarbeit abgesagt — Kulanz

Answers

201 CreatedProjectWriteOff What was waived, per family.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

from? string Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. 2026-08-14
until? string Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. 2026-08-14

Answers

200 ProjectWriteOffCut What this project’s open work would waive.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

customerId* string (uuid) The customer the project is for. 404 when you cannot see it. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project* object The project.
project.name* string
project.aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
project.visibility? "public" | "private" Who may see the project. public is the whole organization.
project.hourlyRateMinor? integer The project’s default hourly rate in minor units, as one rule from the beginning. Not together with rateTemplateProjectId. 9900
project.rateTemplateProjectId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project.tasks? object[] The tasks to create. No two may share a name, compared case-insensitively.
project.personIds? string (uuid)[] The people to assign. You are assigned anyway, named here or not.

Answers

201 ProjectSetupResult What was created.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Query parameters

projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
includeArchived? string
bookable? string

Answers

200 object The tasks. { data: Task[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Request body — application/json

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string
billingMode? "hourly" | "fixed_price" | "productive" | "internal" 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 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
isBillabledeprecated? boolean 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.
isProductivedeprecated? boolean 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 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

201 Task The created task.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
get /api/v1/tasks/{id} #

Get a task

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 Task The task.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

name? string
billingMode? "hourly" | "fixed_price" | "productive" | "internal" 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 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
isBillabledeprecated? boolean Deprecated, use billingMode. true makes the task hourly. false makes it internal, or leaves it productive when it already is.
isProductivedeprecated? boolean 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 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

200 Task The updated task.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The task is deleted.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
post /api/v1/tasks/{id}/archive #

Archive or restore a task

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

archived* boolean

Answers

200 Task The task.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Answers

200 object Every task the caller may book, in picker order. { data: BookableTask[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

days? integer
limit? integer

Answers

200 object The tasks, most recent first. { data: RecentTask[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

prefix? string Only names starting with this text, case-insensitively. %, _ and \ are taken literally.
projectId? string (uuid) Leave out every name this project already carries, archived tasks included — a project cannot hold a name twice. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
exclude? string[] 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 How many suggestions to answer at most.

Answers

200 object The suggestions, most common first. { data: TaskNameSuggestion[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

carries its own credential

Path parameters

token* string Opaque signed token from the resource’s icon.url.

Answers

200 string (binary) The image. image/webp, image/png
304 no body Not modified — the client already has these bytes.
Problems: 404 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Query parameters

personId? string (uuid) Filter by person. Defaults to everybody’s. Naming a specific other person requires the owner or admin role. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
from? string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until? string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
isBillable? string
externalKey? string 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. TIM-199
externalProvider? "linear" | "jira" | "azure_devops" | "github" | "generic" Only entries carrying a live reference in this ticket system.
hasExternalReference? string true = only entries with at least one live reference, false = only entries with none.
running? string true = only running entries.
harvestExport? "synced" | "pending" 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" '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
cursor? string Keyset cursor: pass the previous page's nextCursor back unchanged, together with the same orderBy.
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 TimeEntryList The time entries.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
notes? string | null
editReason? string 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

201 TimeEntry The created entry.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
get /api/v1/time-entries/{id} #

Get a time entry

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 TimeEntry The time entry.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

taskId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt? string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt? string (date-time) | null 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. 2026-07-29T09:00:00.000Z
notes? string | null
editReason? string 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

200 TimeEntry The updated entry.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Query parameters

editReason? string 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

204 no body The entry was deleted.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The tickets on this entry. { data: ExternalReference[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

provider* "linear" | "jira" | "azure_devops" | "github" | "generic"
key* string 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. TIM-199
url? string | null 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. https://linear.app/suora/issue/TIM-199
title? string | null A label for the ticket. Trimmed; blank is stored as null.

Answers

200 ExternalReference This ticket was already attached.
201 ExternalReference The ticket was attached.
202 object Tickets are switched off for this organization; nothing was stored. { attached: false }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
referenceId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The ticket was detached.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The entry's revisions, oldest first — position counts from 0 in that order. { data: TimeEntryRevision[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

parts* object[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
parts.startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
parts.endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
editReason? string 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

200 SplitTimeEntryResult The pieces, in clock order.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
post /api/v1/time-entries/{id}/stop #

Stop a running timer

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

endedAt? string (date-time) 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. 2026-07-29T09:00:00.000Z

Answers

200 TimeEntry The stopped entry.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

entryIds* string (uuid)[]
editReason? string 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

200 BatchTimeEntryResult What each named entry did.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

from* string (date-time)
until* string (date-time)
personId? string (uuid) Whose deleted entries. Defaults to the caller; naming a specific other person requires the owner or admin role. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 object The deleted entries, oldest first by the snapshot’s own startedAt. { data: DeletedTimeEntry[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

personId? string (uuid) Filter by person. Defaults to everybody’s. Naming a specific other person requires the owner or admin role. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId? string (uuid) Filter to the projects a budget covers. Narrows projects only, exactly as the list's own budgetId does. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
from? string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until? string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
isBillable? string
externalKey? string 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. TIM-199
externalProvider? "linear" | "jira" | "azure_devops" | "github" | "generic" Only entries carrying a live reference in this ticket system.
hasExternalReference? string true = only entries with at least one live reference, false = only entries with none.
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 string The filtered entries, one row per entry, oldest filter match first. text/csv
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
notes? string | null
affected* object[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
affected.keep* boolean
editReason? string 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

201 TimeEntry The inserted entry.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
affected* string (uuid)[] 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 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

201 BreakPeriod The recorded break.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Answers

200 object The most recently stopped entry, or null. { data: TimeEntryWithContext | unknown | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

entryIds* string (uuid)[]
notes? string | null

Answers

200 TimeEntry The merged entry.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

taskId* string (uuid) The task the hours would be booked on. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) When they would start. The day this falls in is what decides. 2026-07-29T09:00:00.000Z
entryId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object What this write may do. { data: WritePolicy }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

from* string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
personId? string (uuid) Defaults to the caller. Naming a specific other person requires the owner or admin role. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
includeRunning? string 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.
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 TimeProgress The range’s progress.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

days? integer
limit? integer

Answers

200 object The suggested notes, most suggestible first. { data: string[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

entryIds* string (uuid)[]
taskId* string (uuid) The task every named entry moves onto. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
editReason? string 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

200 BatchTimeEntryResult What each named entry did.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

month* string A calendar month, YYYY-MM. 2026-08
personId? string (uuid) 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). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
limit? integer
cursor? string Keyset cursor within the month: pass the previous page's nextCursor back unchanged, together with the same month and personId.
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 object 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: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Answers

200 object The running entry, or null. { data: TimeEntryWithContext | unknown | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
notes? string | null
startedAt? string (date-time) 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. 2026-07-29T09:00:00.000Z
stopRunning? boolean
editReason? string 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

201 TimeEntry The started entry.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

from* string (date-time)
until* string (date-time)
personId? string (uuid) Defaults to the caller. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 object The total. { totalSeconds: integer }
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

thymian:invoices.readsessionaccess tokenOAuth

Query parameters

status? "draft" | "transferred" | "withdrawn" '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. draft
customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
ids? string Comma-separated invoice ids: only these. The drafts one request created are listed together this way (ADR-2026-10-07-119RC).
limit? integer
createdBefore? string (date-time) Keyset cursor from nextCursor, for the default orderBy: 'createdAt'. 2026-07-29T09:00:00.000Z
orderBy? "createdAt" | "updatedAt" '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 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

200 InvoiceList The invoices.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Request body — application/json

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string (uuid)[]
budgetIds? string (uuid)[] 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)[] 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 Inclusive lower bound on the entry start, or null for no lower bound. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on the entry start. 2026-07-29T09:00:00.000Z
grouping* "member" | "project" | "task" | "entry" 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. project
notes? string | null Free text for the invoice. Never filled from a time entry’s notes, which may be internal.

Answers

201 InvoiceWithLines The created draft.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
get /api/v1/invoices/{id} #

Get an invoice with its lines

thymian:invoices.readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 InvoiceWithLines The invoice.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

notes* string | null

Answers

200 InvoiceWithLines The updated draft.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The invoice was deleted.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 InvoiceWithLines The invoice, with whatever the tool now says.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 ExportedInvoice The invoice, with the exported document recorded.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 InvoiceWithLines The invoice, with the claim released.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

documentId* string The document's id in the invoicing tool the export went to.

Answers

200 InvoiceWithLines The invoice, with the document recorded.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

number* string | null
documentDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
dueDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
url* string | null
sentDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
paidDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14

Answers

200 InvoiceWithLines The invoice, with the document recorded.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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).

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

recordedDocument? RecordedDocumentInput

Answers

200 InvoiceWithLines The transferred invoice.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 InvoiceWithLines The withdrawn invoice.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

invoiceId? string (uuid) 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". 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object Every live allowance month that disagrees with its agreement. Empty is the normal answer. { data: AllowanceDivergence[], scanned: integer, truncated: boolean }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Query parameters

limit? integer 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. 500
cursor? string 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) 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". 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object Every live claim that disagrees with its entry. Empty is the normal answer. { data: Divergence[], nextCursor: string | null, scanned: integer, totalClaims: integer | null }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Query parameters

customerId? string (uuid) 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. 018f3b0c-2f31-7c4a-9a1d-4f2c1b9e77aa

Answers

200 EasybillOutstanding The outstanding total.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Answers

200 ExternallyBilledSummary Everything another system has billed.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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:<id>:<number>, 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.

session only

Request body — application/json

selector* ExternalClaimSelector
invoice* object | null The invoice to attribute these hours to, or null to clear the reference — the undo of a wrong repair.
invoice.harvestInvoiceId* string Harvest’s own invoice id — the segment that identifies the document. Opaque; nothing does arithmetic on it. 19783512
invoice.harvestInvoiceNumber* string | null The number a person recognises, or null for an invoice Harvest gave none. 1042

Answers

200 ExternalClaimRepair What the repair wrote.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

Answers

200 ExternalClaimRepair What was released.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Request body — application/json

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string (uuid)[]
budgetIds? string (uuid)[] 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)[] 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 Inclusive lower bound on the entry start, or null for no lower bound. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on the entry start. 2026-07-29T09:00:00.000Z
grouping* "member" | "project" | "task" | "entry" 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. project

Answers

200 InvoiceDraft The lines this selection would produce.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Query parameters

customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 RecordedOutstanding The outstanding total.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:invoice-status.readsessionaccess tokenOAuth

Query parameters

status? "draft" | "transferred" | "withdrawn" '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. draft
customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
periodFrom? string (date-time) Only invoices whose own billed period touches this window — the period the invoice states, not when it was transferred. Omit for no lower bound. 2026-07-29T09:00:00.000Z
periodUntil? string (date-time) The other end of the period-overlap window. Required together with periodFrom. 2026-07-29T09:00:00.000Z
limit? integer
createdBefore? string (date-time) Keyset cursor from nextCursor: newest invoice first, paginated by created time. 2026-07-29T09:00:00.000Z

Answers

200 InvoiceStatusList The invoices.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Query parameters

includeCurrentMonth? string 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

200 UnbilledSummary Everything still to be billed.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Answers

200 InvoicingProviderConnection The connected tool, if any.
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

session only

Answers

200 HarvestConnection The connection.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

accountId* string Harvest's numeric account id, from the account's own settings. 1234567
accessToken* string A personal access token, from the account's own settings.
confirmDifferentEmail? boolean

Answers

200 HarvestConnection The connection.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
delete /api/v1/harvest #

Disconnect this person’s Harvest account

Removes the stored token. Entries already exported to Harvest are unaffected.

session only

Answers

204 no body Disconnected.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

timeEntryIds* string (uuid)[]

Answers

200 HarvestExportResult Per-entry results.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

timeEntryIds* string (uuid)[] 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) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 HarvestAdoptResult Per-entry results.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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).

session only

Request body — application/json

timeEntryIds* string (uuid)[] 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) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 HarvestCompareResult Per-entry comparison results.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

from* string 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. 2026-08-14
to* string Inclusive, and never before from. 2026-08-14
preview* boolean When true, nothing is written and every result says what would happen.
harvestTimeEntryIds? string[] 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. ["987654321"]
personId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 HarvestImportResult Per-Harvest-entry results.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

harvestTimeEntryId* string 1234567890

Answers

204 no body Forgotten. The next import may fetch these hours again.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
get /api/v1/harvest/projects #

List Harvest projects, for the mapping picker

Reads live from Harvest, through this person’s stored connection.

session only

Answers

200 HarvestProjectList The Harvest projects.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

harvestProjectId* string 20

Answers

200 HarvestTaskList The Harvest task assignments.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

from* string A calendar day, YYYY-MM-DD. 2026-08-14
to* string A calendar day, YYYY-MM-DD. 2026-08-14
preview? boolean
personId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 HarvestSecondsResult Per-entry results for the period.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

from* string Inclusive. Handed to Harvest as its own report bound, so the range is meant in Harvest's time zone — not the organization's. 2026-08-14
to* string Inclusive, and never before from. 2026-08-14
personId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 HarvestPeriodSummary Harvest’s own totals for the range.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
get /api/v1/projects/{id}/harvest #

Read a project’s Harvest mapping

The Harvest project this project exports to, or null when unlinked.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 HarvestProjectLink The mapping, or null.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

harvestProjectId* string 20

Answers

200 HarvestProjectMapping The mapping.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
delete /api/v1/projects/{id}/harvest #

Forget a project’s Harvest mapping

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Forgotten.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

from* string 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. 2026-08-14
to* string Inclusive, and never before from. 2026-08-14
preview* boolean When true, nothing is written and every result says what would happen.

Answers

200 HarvestProjectTeamImportResult Per-member, per-entry results.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

harvestProjectId* string 20
harvestTaskId* string 30

Answers

200 HarvestTaskMapping The mapping.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
delete /api/v1/tasks/{id}/harvest #

Forget a task’s Harvest mapping

thymian:projects.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Forgotten.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenno OAuth

Query parameters

month* string A calendar month, YYYY-MM. 2026-08
day? string One day of month, in the organization’s time zone. Omit for the month. 2026-08-14
personId? string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 AgentUsageSummary The month’s or the day’s usage.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Answers

200 CompanyMetrics The weekly company metrics.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

months? integer How many calendar months to return, 1–36.

Answers

200 CompanyMetricMonths The company metrics by calendar month.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

weeks? integer How many whole weeks to return, 1–104.

Answers

200 CompanyMetricWeeks The company metrics by whole week.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

day? string The organization-local day to inspect. Defaults to today in the organization's time zone. 2026-08-14

Answers

200 OrganizationDashboard The organization dashboard snapshot.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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).

session only

Request body — application/json

day? string The organization-local day the periods are read for. Defaults to today in the organization's time zone. 2026-08-14
requests* (object | object | object | object | object | object | object | object | object | object | object | object | object | object | object)[]

Answers

200 DashboardWidgets One result per request, in request order; none for a request of a kind this server does not know.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

from* string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z

Answers

200 object Tracked hours grouped by project. { data: ProjectHours[], totalSeconds: integer }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

from* string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z

Answers

200 object Every member's hours. { data: UserHours[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

projectId? string (uuid) The project to read. Exactly one of projectId and customerId.
customerId? string (uuid) The customer to read, across every project of it the caller can see. Exactly one of projectId and customerId.
groupBy* "person" | "project" | "settlement" What a bar is split by. project needs customerId; settlement needs invoice.read.
from? string (date-time) Inclusive lower bound on startedAt, no earlier than 1 January 2000 in the organization’s zone. Omitted: from the subject’s first entry on. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt, after from and after 1 January 2000, and no later than 1 January 2100, in the organization’s zone. 2026-07-29T09:00:00.000Z

Answers

200 HoursOverTime The subject’s hours, bucketed.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

from* string The first month, YYYY-MM, inclusive. 2026-08
until* string The last month, YYYY-MM, inclusive. 2026-08
personNames? string false leaves out the Person column; Person-ID stays.
customerNames? string false leaves out the Kunde column; Kunde-ID stays.
projectNames? string false leaves out the Projekt column; Projekt-ID stays.

Answers

200 string One row per month, person, customer, project and currency. text/csv
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

personId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
from* string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 PersonActivity The person's activity.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

personId* string (uuid) The subject. Always required — unlike personActivityQuerySchema, this endpoint has no own-data reading: naming yourself still requires revenue.readOthers. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
from* string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
includeRunning? string Fold the subject's own running entry into the figure, valued once as of now — see runningIncluded on the result.

Answers

200 PersonRevenue The person's revenue over the window.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

month* string The month to export, YYYY-MM. 2026-08
limit? integer
cursor? string Keyset cursor: pass the previous page's nextCursor back unchanged.

Answers

200 object The month's documents. { data: ProjectionDocument[], nextCursor: string | null, syncedAt: string }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:reports.readsessionaccess tokenno OAuth

Query parameters

since* string (date-time) 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. 2026-07-29T09:00:00.000Z
limit? integer
cursor? string Keyset cursor within this delta: pass the previous page's nextCursor back unchanged, together with the same since.

Answers

200 object The changed documents. { data: ProjectionChange[], nextCursor: string | null, nextSince: string, fullResyncRecommended: boolean }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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).

thymian:reports.readsessionaccess tokenno OAuth

Answers

200 object Every member's project count. { data: UserProjectCount[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

from* string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
personId? string (uuid) Only entries tracked by this person. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
provider? "linear" | "jira" | "azure_devops" | "github" | "generic"
key? string One ticket, read in the spelling its system uses — eng-1 finds ENG-1. TIM-199

Answers

200 object Tracked hours grouped by ticket. { rows: TicketReportRow[], truncated: boolean }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

from* string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
personId? string (uuid) Only entries tracked by this person. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
provider? "linear" | "jira" | "azure_devops" | "github" | "generic"
key? string One ticket, read in the spelling its system uses — eng-1 finds ENG-1. TIM-199
rounding? "carry" 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. carry

Answers

200 object Tracked hours grouped by day and ticket. { rows: TicketDayReportRow[], truncated: boolean, timeZone: string }
Problems: 400 401 403 422 — RFC 9457, see Errors.

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).

thymian:hints.readsessionaccess tokenOAuth

Answers

200 object Your hint rules. { data: HintRule[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:hints.writesessionaccess tokenOAuth

Request body — application/json

source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
subject* string
path? HintPath
kind* "presence" | "task" | "ignore" | "customer" | "project" | "absence" 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. task
taskId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 HintRule The rule.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:hints.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The rule was deleted.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

thymian:hints.readsessionaccess tokenOAuth

Query parameters

source? string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
q? string Case-insensitive substring match against subject or details.
from? string (date-time) Inclusive lower bound on startedAt. 2026-07-29T09:00:00.000Z
until? string (date-time) Exclusive upper bound on startedAt. 2026-07-29T09:00:00.000Z
resolved? string false = only hints still to act on, true = only resolved ones.
limit? integer
startedBefore? string (date-time) Keyset cursor from nextCursor. 2026-07-29T09:00:00.000Z

Answers

200 object The hints. { data: HintWithSuggestion[], nextCursor: string | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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).

thymian:hints.writesessionaccess tokenOAuth

Request body — application/json

source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
externalId? string | null Stable id from the collector, e.g. a session id.
clientHintId? string | null The reporting app’s own idempotency key for this report.
sessionKey? string | null Groups this report with the same session’s other reports; every report is its own observation.
subject* string Short label: what the collector thinks the person was working on.
path? HintPath
details? string | null Optional longer context — a URL, the files touched, the prompt.
startedAt? string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt? string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
signal? "presence" | "absence"

Answers

200 Hint An existing hint was widened or replaced.
201 Hint The reported hint.
Problems: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/hints/{id} #

Get a hint

thymian:hints.readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 Hint The hint.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:hints.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The hint was deleted.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:hints.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

dismissed* boolean true = not worth tracking. false takes the dismissal back.

Answers

200 Hint The hint.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:hints.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

timeEntryId* string (uuid) | null The time entry this hint became, or null to unlink. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 Hint The hint.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

hintIds* string (uuid)[] This person’s own hints to evaluate — up to 50 per request.

Answers

200 HintEvaluation Transient, reviewable suggestions.
Problems: 400 401 403 404 422 429 503 — RFC 9457, see Errors.
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.

thymian:hints.readsessionaccess tokenOAuth

Answers

200 object The observed sources. { data: string[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:hints.readsessionaccess tokenOAuth

Answers

200 object The observed subjects. { data: HintSubject[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

session only

Answers

200 GoogleCalendarConnection The connection.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

calendarIds? string[] Which calendars are observed. An empty array is valid and means nothing is read — there is no value meaning "all". ["anna@suora.com"]
includeDetails? boolean 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

200 GoogleCalendarConnection The updated connection.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Disconnected.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 GoogleCalendarList The calendars.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Answers

200 GoogleCalendarAuthorizeUrl Where to send the browser.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 GoogleCalendarSync What the sync found.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 object The keys. { data: CollectorKey[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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}.

session only

Request body — application/json

name? string How this key is labelled in the list, e.g. the machine it lives on. Laptop

Answers

201 CreatedCollectorKey The new key, shown once.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string

Answers

204 no body The key was revoked.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SlackConnection The connection.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Disconnected.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SlackAuthorizeUrl Where to send the browser.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SlackAuthorizeUrl Where to send the browser.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Switched off.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

includeMessageText* boolean 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

200 SlackConnection The updated connection.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Disconnected.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SlackAuthorizeUrl Where to send the browser.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SlackAuthorizeUrl Where to send the browser.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Switched off.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SlackAuthorizeUrl Where to send the browser.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

statusTemplate? string | null Placeholders {customer}, {project} and {task} are filled in. null restores the neutral default, which names no customer. ⏱️ {project}
statusEmoji? string | null null restores :stopwatch:. :stopwatch:

Answers

200 SlackConnection The updated connection.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Switched off.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
get /api/v1/collectors/usage-keys #

List your usage keys

Your own usage keys for the active organization. lastRequestAt answers "is this agent exporting?".

session only

Answers

200 object The keys. { data: UsageKey[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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}.

session only

Request body — application/json

source* "claude-code" | "codex" The coding agent a usage key reports for. claude-code
device* string The machine the agent runs on, as the key is labelled in the list. pasture

Answers

201 CreatedUsageKey The new key, shown once.
Problems: 400 401 403 422 — RFC 9457, see Errors.
delete /api/v1/collectors/usage-keys/{id} #

Revoke a usage key

The agent stops being able to export immediately. Usage it already exported stays.

session only

Path parameters

id* string

Answers

204 no body The key was revoked.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

resourceLogs? object[] | null
resourceLogs.resource? object | null
resourceLogs.scopeLogs? object[] | null

Answers

200 OtlpExportResponse Accepted.
Problems: 400 401 403 413 415 422 429 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Answers

200 string The event stream. Stays open until the client or the server closes it. text/event-stream
Problems: 400 401 403 — RFC 9457, see Errors.

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.

session only

Answers

200 object The tokens. { data: AccessToken[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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 <token> or X-API-Key: <token>.

session only

Request body — application/json

name* string How this token is labelled in the list, e.g. what uses it. Reporting script
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")[] What the token may do. Required and never defaulted: a token whose permissions nobody chose is the one nobody can reason about later. ["thymian:read"]
expiresInDays? integer Optional lifetime in days. Omit for a token that does not expire — revoking it is then the only way it stops working. 90

Answers

201 CreatedAccessToken The new token, shown once.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string

Answers

204 no body The token was revoked.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

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.

carries its own credential

Request body — application/json

An opaque body — see the OpenAPI document.

Answers

200 object A JSON-RPC response.
Problems: 401 403 — RFC 9457, see Errors.
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.

session only

Request body — application/json

oauthQuery* string
accept* boolean
organizationId? string
scopes? string[]
hintsReadEnabled? boolean
budgetWriteEnabled? boolean
moneyReadEnabled? boolean
billingWriteEnabled? boolean

Answers

200 McpConsentDecision Where to send the browser.
Problems: 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 object The grants, newest first. { data: McpGrant[] }
Problems: 401 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

An opaque body — see the OpenAPI document.

Answers

200 McpGrant The updated grant.
Problems: 401 403 404 409 — RFC 9457, see Errors.

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.

thymian:absence-subjects.readsessionaccess tokenno OAuth

Query parameters

limit? integer At most 100 per page.
cursor? string 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

200 object One page of absence subjects, in stable join order. { data: AbsenceSubject[], nextCursor: string | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Query parameters

fromDay? string Inclusive. Omitted means unbounded. 2026-08-14
untilDay? string Inclusive. Omitted means unbounded. 2026-08-14
personId? string (uuid) Whose absences. A member may only name themselves; owners and admins may name anybody, and reading without it answers with the whole organization’s. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
orderBy? "fromDay" | "updatedAt" '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 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 Only meaningful with orderBy: 'updatedAt'; ignored by the default order.
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 AbsenceList The absences.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

personId? string (uuid) Whose absence. Defaults to the caller; naming somebody else needs owner or admin. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string Inclusive. Equal to fromDay for a single day. 2026-08-14
secondsPerDay? integer | null 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 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. 0.5
note? string | null
source? string Defaults to manual. personio
externalId? string | null
editReason? string 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

201 Absence The recorded absence.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

kind? "vacation" | "sick" | "holiday" | "other"
fromDay? string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay? string A calendar day, YYYY-MM-DD. 2026-08-14
secondsPerDay? integer | null
fractionOfDay? number 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. 0.5
note? string | null
editReason? string 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

200 Absence The corrected absence.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Query parameters

editReason? string 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

204 no body The absence was removed.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The absence's revisions, oldest first — position counts from 0 in that order. { data: AbsenceRevision[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Path parameters

source* string The importing system’s slug. manual is what a person typing an absence in leaves behind, so an importer names itself: personio. personio
externalId* string That system’s own id for this absence. Percent-encode it if it is not URL-safe. personio-absence-4711

Request body — application/json

personId? string (uuid) Whose absence. Defaults to the caller; naming somebody else — including moving an existing absence to somebody else — needs owner or admin. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string Inclusive. Equal to fromDay for a single day. 2026-08-14
secondsPerDay? integer | null 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 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. 0.5
note? string | null Omitting it clears an existing note, for the reason secondsPerDay gives.
editReason? string 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

200 Absence The absence this source already had, as it now stands.
201 Absence The absence this source did not have yet.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

month* string A calendar month, YYYY-MM. 2026-08
personId? string (uuid) 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). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
limit? integer
cursor? string Keyset cursor within the month: pass the previous page's nextCursor back unchanged, together with the same month and personId.
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 object 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: 400 401 403 422 — RFC 9457, see Errors.

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.

carries its own credential

Request body — application/json

name* string
email* string (email)
company* string
message? string

Answers

202 AccessRequestAccepted The request was recorded.
Problems: 403 422 429 — RFC 9457, see Errors.
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.

carries its own credential

Request body — application/json

code* string An invite code, in any casing or spacing — normalized server-side. TIMJ-AAAA-AAAA

Answers

200 InviteCodeCheckResult The code is usable.
Problems: 403 422 429 — RFC 9457, see Errors.

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.

session only

Answers

200 AdminAccessRequestList The access-request queue.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

features* OperatorFeature[] The features an organization founded with the code starts with; [] for none. Duplicates are stored once. ["tour","jami"]

Answers

200 ApproveAccessRequestResult The code just issued.
Problems: 401 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The request was declined.
Problems: 401 404 409 422 — RFC 9457, see Errors.
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.

session only

Answers

200 PlatformAppVersions The current app-version distribution.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Answers

200 PlatformAccessLog The most recent access log rows.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Answers

200 AdminInviteCodeList Every invite code, newest first.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Request body — application/json

features* OperatorFeature[] The features an organization founded with the code starts with; [] for none. Duplicates are stored once. ["tour","jami"]
label* string
email? string (email)
maxUses* integer
expiresAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

Answers

201 AdminInviteCode The invite code just minted.
Problems: 401 404 422 — RFC 9457, see Errors.
post /api/v1/admin/invite-codes/{id}/revoke #

Revoke an invite code (operators only)

A code already revoked, or unknown, answers 404 either way.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The code was revoked.
Problems: 401 404 — RFC 9457, see Errors.
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).

session only

Answers

200 PlatformOrganizations Every organization, newest first.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string

Answers

200 PlatformOrganizationDetail The organization and its members.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string

Request body — application/json

proof* object
proof.challengeId* string (uuid)
proof.response* object
confirmation* string

Answers

204 no body Organization and remaining data deleted.
Problems: 400 401 403 409 422 429 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string

Answers

204 no body The organization is left out of the analytics.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string

Answers

204 no body The organization counts in the analytics.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

session only

Path parameters

id* string
feature* OperatorFeature 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

204 no body The feature is on for this organization.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string
feature* OperatorFeature 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

204 no body The feature is off for this organization.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string
userId* string better-auth account id — 32 characters of [A-Za-z0-9], not a UUID. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Request body — application/json

proof* object
proof.challengeId* string (uuid)
proof.response* object
confirmation* string

Answers

204 no body Account deleted; historical person data retained.
Problems: 400 401 403 409 422 429 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string
userId* string better-auth account id — 32 characters of [A-Za-z0-9], not a UUID. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
feature* OperatorFeature 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

204 no body The feature is on for this member.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string
userId* string better-auth account id — 32 characters of [A-Za-z0-9], not a UUID. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
feature* OperatorFeature 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

204 no body This member’s own grant is gone.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 PlatformReleases Every product’s current release/deploy status.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Answers

200 PlatformUsage Every organization, most recent time entry first.
Problems: 401 404 — RFC 9457, see Errors.
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.

session only

Path parameters

userId* string better-auth account id — 32 characters of [A-Za-z0-9], not a UUID. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Request body — application/json

proof* object
proof.challengeId* string (uuid)
proof.response* object

Answers

201 ImpersonationSession The new support session.
Problems: 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

userId* string better-auth account id — 32 characters of [A-Za-z0-9], not a UUID. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Query parameters

range? "24h" | "7d" | "30d" How far back Loki should be queried. 7d

Answers

200 PlatformPushLogs Recent structured APNs events, newest first.
Problems: 401 404 503 — RFC 9457, see Errors.
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.

session only

Path parameters

userId* string better-auth account id — 32 characters of [A-Za-z0-9], not a UUID. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 PlatformPushTokens This person's currently registered tokens.
Problems: 401 404 — RFC 9457, see Errors.

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.

carries its own credential

Request body — application/json

errorId* string (uuid)
source* "panic" | "webview" | "upload" | "collector" | "api" | "command"
kind* "Panic" | "UnexpectedStatus" | "InvalidResponse" | "Refused" | "UploadFailing" | "UnreadableInput" | "UnreadableLocalState" | "Error" | "TypeError" | "ReferenceError" | "SyntaxError" | "RangeError" | "URIError" | "EvalError" | "AggregateError" | "UnknownError"
status? integer The HTTP status; only for api, upload and collector reports.
operation? string
frames* string[]
build* string

Answers

200 object Report logged; requestId also appears in X-Request-Id. { errorId: string (uuid), requestId: string (uuid) }
Problems: 413 422 429 — RFC 9457, see Errors.
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.

carries its own credential

Request body — application/json

errorId* string (uuid)
kind* "react" | "route" | "error" | "unhandledrejection"
name* "Error" | "TypeError" | "ReferenceError" | "SyntaxError" | "RangeError" | "URIError" | "EvalError" | "AggregateError" | "UnknownError"
frames* string[]
route* string
build* string

Answers

200 object Report logged; requestId also appears in X-Request-Id. { errorId: string (uuid), requestId: string (uuid) }
Problems: 403 413 422 429 — RFC 9457, see Errors.

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.

sessionaccess tokenOAuthdeprecated

Request body — application/json

installationId* string (uuid)
app* "desktop" | "cli" | "ios" | "browser-extension" | "raycast"
version* string
platform* string
architecture* string

Answers

204 no body The latest installation sighting was stored.
Problems: 400 401 403 422 — RFC 9457, see Errors.

Authentication

get /api/v1/auth/providers #

Available interactive sign-in providers

carries its own credential

Answers

200 AuthProviders 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.

carries its own credential

Path parameters

token* string Opaque signed token from user.avatar.url.

Answers

200 string (binary) The image. image/webp, image/png
304 no body Not modified — the client already has these bytes.
Problems: 404 — RFC 9457, see Errors.

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.

thymian:billing.readsessionaccess tokenOAuth

Query parameters

customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
billingRuleId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
openOnly? string Only proposals not yet complete. Defaults to every proposal.

Answers

200 object The proposals. { data: BillingProposalListItem[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/billing-proposals/{id} #

One proposal, with its allocations

thymian:billing.readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 BillingProposalDetail The proposal.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:billing-proposals.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
allocationId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

toProposalId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
reason* string | null

Answers

204 no body Moved.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:billing-proposals.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 BillingProposalDetail The proposal.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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).

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

notes? string | null
grouping? "member" | "project" | "task" | "entry" Overrides the proposal’s own default grouping for this one invoice.

Answers

201 object The invoice. { id: string (uuid) }
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 ProposalInvoicePreview The preview.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The services. { data: BillingAllocationService[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

Billing rules

get /api/v1/billing-rules #

List recurring billing rules

thymian:billing.readsessionaccess tokenOAuth

Query parameters

customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
status? "active" | "paused"

Answers

200 object The rules. { data: BillingRuleListItem[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:billing-rules.writesessionaccess tokenOAuth

Request body — application/json

customerId* string (uuid) The customer this rule bills. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string A human-readable name for the rule.
projectMode* "selected" | "all" selected names explicit projects; all dynamically covers every project of the customer, including ones created later.
projectIds* string (uuid)[] Required, non-empty, when projectMode is selected; must be empty for all.
recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
firstPeriodStart* string Normalized to a quarter start when recurrence is quarterly. 2026-08
historyMode* "separate-periods" | "collect-history" | "handle-later" 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 Only meaningful, and required, when historyMode is collect-history. 2026-08
responsiblePersonIds* string (uuid)[] The people who receive due/escalation email for this rule.

Answers

201 BillingRule The rule.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
get /api/v1/billing-rules/{id} #

One billing rule

thymian:billing.readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 BillingRule The rule.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The rule was deleted.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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).

thymian:billing-rules.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

projectMode* "selected" | "all" selected names explicit projects; all dynamically covers every project of the customer, including ones created later.
projectIds* string (uuid)[] Required, non-empty, when projectMode is selected; must be empty for all.
effectiveFromPeriod* string A future period. Existing proposals before it keep their prior coverage. 2026-08

Answers

200 BillingRule The rule.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 BillingRule The rule.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 JoinableProjectList The projects, by name.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Query parameters

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
fromMonth? string The month the project joins from. Omitted, the preview starts at suggestedFromMonth. 2026-08

Answers

200 ProjectAdditionPreview The preview.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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).

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
fromMonth* string The month the project joins from: a past, the current, or a future month between the preview’s earliestMonth and latestMonth. 2026-08
createInvoices* boolean 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

200 ProjectAdditionResult The rule, its affected proposals and drafts.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
post /api/v1/billing-rules/{id}/resume #

Resume a paused rule

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

restartFromPeriod? string | null A chosen period to resume from, becoming the rule’s new generation anchor. Omitted resumes from where it left off. 2026-08

Answers

200 BillingRule The rule.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:billing-rules.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

name? string A human-readable name for the rule.
dueDayOfMonth? integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
selectionMode? "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
responsiblePersonIds? string (uuid)[] The people who receive due/escalation email for this rule.

Answers

200 BillingRule The rule.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string Comma-separated project ids. Empty means every project of the customer.
recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
firstPeriodStart* string A calendar month, YYYY-MM. 2026-08

Answers

200 object The periods. { data: HistoricalPeriodPreview[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Query parameters

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string Comma-separated project ids. Empty means every project of the customer.
recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
firstPeriodStart* string A calendar month, YYYY-MM. 2026-08
dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
grouping* "member" | "project" | "task" | "entry" The default invoice grouping a proposal generated under this rule freezes.
historyMode? "separate-periods" | "collect-history" | "handle-later" 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 The last period the catch-up proposal reaches, for collect-history. 2026-08

Answers

200 BillingRulePeriodPreview The periods.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Query parameters

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string Comma-separated project ids. Empty means every project of the customer.
recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
firstPeriodStart* string A calendar month, YYYY-MM. 2026-08
dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10

Answers

200 object The planned hours per period. { data: PlannedPreviewPeriod[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Query parameters

from* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
until* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

Answers

200 object The dismissals. { data: BreakDismissal[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

Answers

201 BreakDismissal The recorded dismissal.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

from* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
until* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
personId? string (uuid) Whose breaks. Defaults to the caller; naming a specific other person requires the owner or admin role. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 object The breaks. { data: BreakPeriod[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
post /api/v1/break-periods #

Record an explicit break

A break answers a gap in the UI. It is not tracked or billable time.

thymian:tracksessionaccess tokenOAuth

Request body — application/json

startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

Answers

201 BreakPeriod The recorded break.
Problems: 400 401 403 422 — RFC 9457, see Errors.
delete /api/v1/break-periods/{id} #

Remove one of your breaks

thymian:tracksessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The break was removed.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

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.

carries its own credential

Path parameters

token* string Opaque signed token, with a .png suffix, from a report.

Answers

200 string (binary) The chart. image/png
Problems: 404 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

customerId? string (uuid) The customer that owns this budget. Every projectIds entry and referenced overflow budget must belong to this customer. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
includeArchived? string Whether archived budgets should be included. Defaults to false.

Answers

200 object The budgets. { data: Budget[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenOAuth

Request body — application/json

customerId* string (uuid) The customer that owns this budget. Every projectIds entry and referenced overflow budget must belong to this customer. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string A human-readable name for the agreement; it remains stable when the project composition changes. Acme Retainer
validFromMonth* string The first calendar month covered by the budget, inclusive. 2026-08
validUntilMonth* string | null The last calendar month covered by the budget, inclusive. null keeps it open-ended. 2026-08
budgetedSeconds* integer 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. 540000
kind? "monthly" | "total" 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. monthly
overflowIntoId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
overflowFromId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds* string (uuid)[] One or more project IDs covered by the budget. Every project must belong to customerId.

Answers

201 Budget The budget.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
get /api/v1/budgets/{id} #

One budget

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 Budget The budget.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

name? string
validFromMonth? string A calendar month, YYYY-MM. 2026-08
validUntilMonth? string | null A calendar month, YYYY-MM. 2026-08
budgetedSeconds? integer 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. 540000
kind? "monthly" | "total" 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. monthly
overflowIntoId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string (uuid)[]

Answers

200 Budget The budget.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Deleted.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-08

Request body — application/json

adjustmentSeconds* integer 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. 46800
note? string | null Optional explanation for why this month differs. Free text; null clears the note. Übertrag aus Juli

Answers

200 BudgetAdjustment The adjustment.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-08

Answers

204 no body Removed.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 BudgetAlerts The thresholds.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

scope* "month" | "total" 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. month
percent* integer 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. 80

Answers

201 BudgetAlertThreshold The threshold.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
alertId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Removed.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

archived* boolean

Answers

200 Budget The budget.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 BudgetReports The reports.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

sendWeekdays* integer[] 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. [1,4]
sendHour* integer 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. 8

Answers

200 BudgetReportSubscription The subscription.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Removed.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

slackChannelId* string The Slack channel id, as GET /budgets/report-channels lists it.
sendWeekdays* integer[] 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. [1,4]
sendHour* integer 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. 8

Answers

201 BudgetReportSubscription The subscription.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
subscriptionId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

sendWeekdays* integer[] 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. [1,4]
sendHour* integer 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. 8

Answers

200 BudgetReportSubscription The subscription.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
subscriptionId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Removed.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

thymian:readsessionaccess tokenOAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 BudgetTrajectoryDetail The trajectory and its days.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

customerId? string (uuid) Limit the answer to the budgets of one customer. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The watched budgets. { data: BudgetAlerts[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:readsessionaccess tokenOAuth

Query parameters

fromMonth* string The first consumption month to return, inclusive. 2026-08
untilMonth* string The final consumption month to return, inclusive. It cannot precede fromMonth; the range may cover at most 24 months. 2026-08
customerId? string (uuid) The customer that owns this budget. Every projectIds entry and referenced overflow budget must belong to this customer. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The months. { data: BudgetMonth[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenno OAuth

Answers

200 object The channels. { data: BudgetReportChannel[] }
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

thymian:budgets.writesessionaccess tokenOAuth

Request body — application/json

replaceIds* string (uuid)[] 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 The new budget that replaces every replaceIds budget in the same transaction.
successor.customerId* string (uuid) The customer that owns this budget. Every projectIds entry and referenced overflow budget must belong to this customer. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.name* string A human-readable name for the agreement; it remains stable when the project composition changes. Acme Retainer
successor.validFromMonth* string The first calendar month covered by the budget, inclusive. 2026-08
successor.validUntilMonth* string | null The last calendar month covered by the budget, inclusive. null keeps it open-ended. 2026-08
successor.budgetedSeconds* integer 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. 540000
successor.kind? "monthly" | "total" 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. monthly
successor.overflowIntoId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.overflowFromId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.projectIds* string (uuid)[] One or more project IDs covered by the budget. Every project must belong to customerId.

Answers

201 Budget The successor.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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).

thymian:readsessionaccess tokenOAuth

Answers

200 BudgetTrajectories The current month’s trajectories.
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

thymian:readsessionaccess tokenOAuth

Query parameters

personId? string (uuid) Only this person’s versions. Omitted, every person’s. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 CapacityAgreements The stored versions.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:organization.writesessionaccess tokenno OAuth

Request body — application/json

CreateCapacityAgreement

Answers

201 CapacityAgreement The stored version.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

thymian:organization.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

validFromDay? string A calendar day, YYYY-MM-DD. 2026-08-14
kind? "share" | "fixed" 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 50.00
soldSecondsPerWeek? integer | null
employment? "internal" | "external" internal (employed) or external (freelancer).
inTeamMetric? boolean
note? string | null

Answers

200 CapacityAgreement The corrected version.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:organization.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The version is gone.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

Changelog

get /api/v1/changelog/{kind} #

A product's changelog

session only

Path parameters

kind* "cli" | "cli-native" | "desktop" | "extension" | "firefox-extension" | "safari-extension" | "web" | "ios"

Answers

200 object 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.

session only

Path parameters

kind* "cli" | "cli-native" | "desktop" | "extension" | "firefox-extension" | "safari-extension" | "web" | "ios"

Answers

200 object 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

session only

Path parameters

organizationSlug* string

Answers

200 object 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: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/customer-portal/{organizationSlug}/branding #

Get public customer portal branding

carries its own credential

Path parameters

organizationSlug* string

Answers

200 object Portal branding. { organizationSlug: string, displayName: string, accentColor: string, welcomeMessage: string | null, logoUrl: string (uri) | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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).

session only

Path parameters

organizationSlug* string

Answers

200 object Shared budget consumption, oldest month first. { months: object[], months.month: string, months.budgets: object[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
get /api/v1/organization/customer-portal #

Get customer portal settings and invitations

session only

Answers

200 object 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: 400 401 403 422 — RFC 9457, see Errors.
put /api/v1/organization/customer-portal/branding #

Update customer portal branding

session only

Request body — application/json

displayName* string | null
accentColor* string
welcomeMessage* string | null

Answers

200 object Updated branding. { displayName: string | null, accentColor: string, welcomeMessage: string | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

budgetIds* string (uuid)[]

Answers

200 object Updated budget access. { selectedBudgetIds: string (uuid)[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
post /api/v1/organization/customer-portal/invitations #

Invite a customer contact

session only

Request body — application/json

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
email* string (email)

Answers

200 object Invitation sent. { status: "sent" }
Problems: 400 401 403 422 — RFC 9457, see Errors.
delete /api/v1/organization/customer-portal/invitations/{id} #

Revoke a customer portal invitation

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Invitation revoked.
Problems: 400 401 403 422 — RFC 9457, see Errors.
put /api/v1/organization/customer-portal/projects #

Choose projects visible in the customer portal

session only

Request body — application/json

An opaque body — see the OpenAPI document.

Answers

200 object Updated project access. { mode: "all" | "selected", selectedProjectIds: string (uuid)[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

easybillCustomerId* string 4711

Answers

200 EasybillCustomerMapping The mapping.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
delete /api/v1/customers/{id}/easybill #

Forget the Easybill mapping

The next export re-resolves it — by name again, or creates a new stub.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Forgotten.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
get /api/v1/easybill #

The Easybill connection

configured: false hides the settings card and refuses an export.

session only

Answers

200 EasybillConnection The connection.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

apiKey* string Easybill's Bearer API key, from the account's own settings.
easybillServicePositionId? string The Easybill article's id. 236898474

Answers

200 EasybillConnection The connection.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Disconnected.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Answers

200 EasybillCustomerMappingList The mappings.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Query parameters

companyName? string Muster
page? integer 1

Answers

200 EasybillCustomerList One page of Easybill customers.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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

session only

Request body — application/json

easybillExpensePositionId* string | null The Easybill article's id. 236898474

Answers

200 EasybillConnection The connection.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

easybillServicePositionId* string The Easybill article's id. 236898474

Answers

200 EasybillConnection The connection.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

purpose? "service" | "expense" service
page? integer 1

Answers

200 EasybillServiceList One page of Easybill service articles.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.

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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

sevdeskContactId* string sevdesk's own id. 900000007

Answers

200 SevdeskCustomerMapping The mapping.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Forgotten.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
get /api/v1/sevdesk #

The sevdesk connection

configured: false hides the settings card and refuses an export.

session only

Answers

200 SevdeskConnection The connection.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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

session only

Request body — application/json

apiToken* string The account's sevdesk API token, from the settings of the sevdesk user.
servicePartId? string sevdesk's own id. 900000007
expensePartId? string sevdesk's own id. 900000007
contactPersonId? string sevdesk's own id. 900000007
taxRuleId? 1 | 11 The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1

Answers

200 SevdeskConnectionResult The connection.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Disconnected.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SevdeskAccount The account’s tax settings.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

contactPersonId* string sevdesk's own id. 900000007

Answers

200 SevdeskConnection The connection.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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“.

session only

Answers

200 SevdeskContactPersonList The account’s users.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

name? string Muster
page? integer 1

Answers

200 SevdeskContactList One page of sevdesk contacts.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Answers

200 SevdeskCustomerMappingList The mappings.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

expensePartId* string | null sevdesk's own id. 900000007

Answers

200 SevdeskConnection The connection.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Answers

200 SevdeskPartList One page of sevdesk parts.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

servicePartId* string sevdesk's own id. 900000007

Answers

200 SevdeskConnection The connection.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

taxRuleId* 1 | 11 The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1

Answers

200 SevdeskConnectionResult The connection.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.

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.

session only

Answers

200 Dashboards The dashboards visible to the caller.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

name* string
visibility? "private" | "shared" 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

Answers

201 Dashboard The saved dashboard.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Path parameters

dashboardId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 Dashboard The dashboard.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

dashboardId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

name? string
visibility? "private" | "shared" 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

Answers

200 Dashboard The changed dashboard.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

dashboardId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body The dashboard is gone.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

Downloads

get /api/v1/downloads #

List client downloads

session only

Answers

200 object 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 no body Downloads are not configured or no matching releases exist.
get /api/v1/downloads/{kind} #

Download a client

session only

Path parameters

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"

Answers

200 string (binary) Release asset bytes, streamed through Timjami. application/octet-stream
404 no body 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.

session only

Answers

201 object A ten-minute, single-use installer capability, as two scripts. { installUrl: string (uri), powershellUrl: string (uri), expiresAt: string (date-time) }
404 no body 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.

carries its own credential

Path parameters

ticket* string

Answers

200 string POSIX shell installer for the native CLI. text/x-shellscript
404 no body 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.

carries its own credential

Path parameters

ticket* string
kind* "cli-linux-x86_64" | "cli-linux-aarch64" | "cli-macos-aarch64" | "cli-macos-x86_64" | "cli-windows-x86_64"

Answers

200 string (binary) The native CLI binary for this platform, streamed through Timjami. application/octet-stream
404 no body 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.

carries its own credential

Path parameters

ticket* string

Answers

200 string PowerShell installer for the native CLI. text/plain
404 no body The capability is unknown, expired, used, or unavailable.
post /api/v1/downloads/cli/install-tickets #

Create a one-time CLI installer URL

session only

Answers

201 object A ten-minute, single-use installer capability. { installUrl: string (uri), expiresAt: string (date-time) }
404 no body 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.

carries its own credential

Path parameters

ticket* string

Answers

200 string POSIX shell installer for the native CLI. text/x-shellscript
404 no body The capability is unknown, expired, used, or unavailable.
get /api/v1/downloads/desktop/updater-artifact/{tag} #

Download the desktop app updater artifact

session only

Path parameters

tag* string

Headers

x-thymian-update-channel? string 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

200 string (binary) The signed updater bundle, streamed through Timjami. application/octet-stream
404 no body 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)

session only

Path parameters

target* string
arch* string
currentVersion* string

Headers

x-thymian-update-channel? string 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

200 object A newer desktop build is published. { version: string, notes: string, url: string (uri), signature: string }
204 no body Nothing newer than the installed build.
404 no body 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

carries its own credential

Answers

200 object 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 no body Downloads are not configured.
get /api/v1/downloads/firefox-extension/xpi/{tag} #

Download the signed Firefox extension

carries its own credential

Path parameters

tag* string

Answers

200 string (binary) The package Mozilla signed for this release. application/x-xpinstall
404 no body 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.

session only

Query parameters

customerId? string (uuid) Only this customer’s agreements. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Only agreements that touch this project, through a task or an item. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The agreements. { data: FixedPriceAgreement[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string

Answers

201 FixedPriceAgreement The agreement.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
get /api/v1/fixed-price-agreements/{id} #

Get a fixed-price agreement

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 FixedPriceAgreement The agreement.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

name? string

Answers

200 FixedPriceAgreement The agreement.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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).

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Deleted.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Query parameters

from? string 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. 2026-08-14
until? string Last calendar day, inclusive. Omitted: no end. 2026-08-14

Answers

200 FixedPriceControlling The agreement’s controlling.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The revisions, newest first. { data: FixedPriceRevision[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Query parameters

agreementId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
standalone? string Only items without an agreement — the standalone expenses.

Answers

200 object The items. { data: FixedPriceItem[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

agreementId? string (uuid) | null Required for a service; omit or null for a standalone expense. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "service" | "expense" 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
quantityHundredths* integer
unit* string
unitPriceMinor* integer
days* string[]
people? object[] 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) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
people.weight* integer The share of the item’s revenue, relative to the other people. Equal by default. 1
easybillPositionId? string | null 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. 236898474
editReason? string 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

201 FixedPriceItem The item.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
get /api/v1/fixed-price-items/{id} #

Get a fixed-price item

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 FixedPriceItem The item.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

agreementId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
description? string
quantityHundredths? integer
unit? string
unitPriceMinor? integer
easybillPositionId? string | null 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. 236898474
days? string[]
people? object[]
people.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
people.weight* integer The share of the item’s revenue, relative to the other people. Equal by default. 1
editReason? string 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

200 FixedPriceItem The item.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Query parameters

editReason? string 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

204 no body Deleted.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Query parameters

projectId* string (uuid) A project’s standalone expenses’ history — the revisions of items without an agreement. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 object The revisions, newest first. { data: FixedPriceRevision[] }
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

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.

session only

Request body — application/json

accountId* string
accessToken* string

Answers

200 object 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: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

accountId? string
accessToken? string
preview? boolean
harvestUserIds? string[]

Answers

200 object 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: 400 401 403 404 422 — RFC 9457, see Errors.

Operator security

get /api/v1/operator-security #

List your operator passkeys and approval status

session only

Answers

200 object 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: 400 401 403 422 429 — RFC 9457, see Errors.
post /api/v1/operator-security/authentication-options #

Request a one-use passkey confirmation for a specific operator action

session only

Request body — application/json

An opaque body — see the OpenAPI document.

Answers

200 object 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: 400 401 403 422 429 — RFC 9457, see Errors.
post /api/v1/operator-security/passkeys #

Finish registering an operator passkey with required user verification

session only

Request body — application/json

name* string
challengeId* string (uuid)
response* object
response.id* string
response.rawId* string
response.type* "public-key"
response.clientExtensionResults* object
response.authenticatorAttachment? "platform" | "cross-platform"
response.response* object

Answers

204 no body Passkey registered.
Problems: 400 401 403 422 429 — RFC 9457, see Errors.
delete /api/v1/operator-security/passkeys/{id} #

Remove your operator passkey after fresh confirmation; keeps the last approved key

session only

Path parameters

id* string (uuid)

Request body — application/json

challengeId* string (uuid)
response* object
response.id* string
response.rawId* string
response.type* "public-key"
response.clientExtensionResults* object
response.authenticatorAttachment? "platform" | "cross-platform"
response.response* object

Answers

204 no body Passkey removed.
Problems: 400 401 403 422 429 — RFC 9457, see Errors.
post /api/v1/operator-security/registration-options #

Prepare an operator passkey; an existing approved key must authorize additional keys

session only

Request body — application/json

name* string
proof? object
proof.challengeId* string (uuid)
proof.response* object

Answers

200 object 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: 400 401 403 422 429 — RFC 9457, see Errors.

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.

session only

Answers

200 PersonioAttendanceExport The state.
Problems: 400 401 403 404 422 503 — RFC 9457, see Errors.
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.

session only

Request body — application/json

personioClientId* string The client id of a Personio API credential (Personio: Einstellungen → Integrationen → API-Zugangsdaten).
personioClientSecret* string 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) 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

200 PersonioAttendanceExport Activated.
Problems: 400 401 403 404 409 422 503 — RFC 9457, see Errors.
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.

session only

Answers

204 no body Deactivated, or never activated.
Problems: 400 401 403 404 409 422 503 — RFC 9457, see Errors.
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.

session only

Answers

200 PersonioPersonMappings The complete person mapping list.
Problems: 400 401 403 404 409 422 503 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid)

Request body — application/json

personioPersonId* string (uuid) | null The Personio mapping item to confirm, or null to revoke.
expectedAssignment* object | null The assignment and version displayed to the administrator, or null for an unassigned person.
expectedAssignment.id* string
expectedAssignment.version* integer

Answers

204 no body Assignment updated in the sync service.
Problems: 400 401 403 404 409 422 503 — RFC 9457, see Errors.

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.

thymian:persons.readsessionaccess tokenOAuth

Query parameters

limit? integer At most 100 per page.
cursor? string 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" Only persons with this status. Omit it to walk every person of the organization — the complete set a mapping integration compares against.

Answers

200 object One page of persons, in stable creation order. { data: Person[], nextCursor: string | null }
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

thymian:planning.writesessionaccess tokenOAuth

Request body — application/json

personId* string (uuid) Who this plans for. Must be a person of the active organization who still holds a membership. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) | null The project target. Keep this field for compatibility with existing clients. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) | null The direct customer target. Exactly one target id is required. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId? string (uuid) | null The direct budget target. Exactly one target id is required. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
weekStartDay* string 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. 2026-09-14
plannedSeconds* integer Whole seconds planned for this week. 0 removes the row — the only way an allocation is deleted. 72000
note? string | null

Answers

200 object The allocation as it now stands, or null when this write removed it. { allocation: PlanningAllocation | unknown | null }
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:planning.writesessionaccess tokenOAuth

Request body — application/json

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
weekStartDay* string 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. 2026-09-14
source* object The existing target whose hours are reduced.
source.projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
source.customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
source.budgetId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
destination* object The target receiving the hours. Must belong to the same customer; a project and budget must be linked.
destination.projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
destination.customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
destination.budgetId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
moveSeconds* integer Whole seconds to move, at most the source hours and at most 604800 seconds at the destination in total.
expectedSourceSeconds* integer The source plannedSeconds from the last overview read.
sourceAllocationId* string (uuid) The id of the source allocation from the last overview read. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
expectedSourceUpdatedAt* string (date-time) The source allocation updatedAt from the last overview read; a changed revision is refused. 2026-07-29T09:00:00.000Z

Answers

200 object The refined source and destination cells. { sourceRemainingSeconds: integer, allocation: PlanningAllocation | unknown | null }
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:planning.writesessionaccess tokenOAuth

Request body — application/json

allocationId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
expectedUpdatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
expectedRemainingSeconds* integer | null 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

200 object Updated planning allocation. { allocation: PlanningAllocation }
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:planning.readsessionaccess tokenOAuth

Query parameters

targetType? "customer" | "person" | "project" | "budget" Restrict to customer, project, person or budget notes. Omit both filters to read all notes.
targetId? string (uuid) Read one exact subject. Requires targetType. Customer selection reads only the customer’s own note, not project or budget notes. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 PlanningContextList Stored planning context.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:planning.writesessionaccess tokenOAuth

Request body — application/json

targetType* "customer" | "person" | "project" | "budget"
targetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
notes* string Free-form planning context. Empty or whitespace-only text clears it. Treated as data, never executable instructions.

Answers

200 PlanningContextList The subject’s context after the write.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

thymian:planning.readsessionaccess tokenOAuth

Query parameters

fromMonth? string A calendar month, YYYY-MM. 2026-08
untilMonth? string A calendar month, YYYY-MM. 2026-08

Answers

200 PlanningForecast Budget forecast for the requested months.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:planning.readsessionaccess tokenOAuth

Query parameters

fromWeek? string The first week to include, inclusive. Defaults to the current ISO week in the organization’s time zone. 2026-09-14
untilWeek? string 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. 2026-09-14

Answers

200 PlanningOverview The staff plan for the requested weeks.
Problems: 400 401 403 422 — RFC 9457, see Errors.

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.

session only

Query parameters

personId? string (uuid) Only this person’s agreements. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId? string (uuid) Only agreements on this task. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Only agreements on this project’s tasks. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) Only agreements on this customer’s projects. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
coveringMonth? string Only agreements whose span covers this month. Every parameter here narrows; none widens. 2026-08

Answers

200 object The agreements. { data: TimeAllowance[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

personId* string (uuid) Who the allowance is for. Must be a person of the active organization who still holds a membership. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId* string (uuid) 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). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
secondsPerMonth* integer The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. 7200
validFromMonth* string 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. 2026-09
validUntilMonth* string | null 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. null
note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

Answers

201 TimeAllowance The agreement.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
get /api/v1/time-allowances/{id} #

One agreement

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 TimeAllowance The agreement.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

secondsPerMonth? integer The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. 7200
validFromMonth? string 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. 2026-09
validUntilMonth? string | null 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. null
note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

Answers

200 TimeAllowance The agreement.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

204 no body Deleted.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-08

Request body — application/json

deltaSeconds* integer 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. -3600
note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

Answers

200 TimeAllowanceAdjustment The adjustment.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-08

Answers

204 no body Removed.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

session only

Request body — application/json

replaceIds* string (uuid)[] 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 The agreement that takes over, written in the same transaction.
successor.personId* string (uuid) Who the allowance is for. Must be a person of the active organization who still holds a membership. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.taskId* string (uuid) 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). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.secondsPerMonth* integer The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. 7200
successor.validFromMonth* string 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. 2026-09
successor.validUntilMonth* string | null 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. null
successor.note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

Answers

201 TimeAllowance The successor.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.

Timer

get /api/v1/timer #

Get the caller’s own timer snapshot

thymian:timersessionaccess tokenOAuth

Answers

200 TimerSnapshot The current timer snapshot.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Answers

200 string The event stream. Stays open until the client or the server closes it. text/event-stream
Problems: 400 401 403 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Request body — application/json

installationId* string (uuid)
environment* "sandbox" | "production"
token* string

Answers

200 IosNotificationPushToken The opted-in installation.
Problems: 400 401 403 422 — RFC 9457, see Errors.
delete /api/v1/timer/forgotten-timer-push-tokens #

Opt this iPhone installation out of forgotten-timer notifications

thymian:timersessionaccess tokenOAuth

Request body — application/json

installationId* string (uuid)

Answers

204 no body The installation, if registered, was forgotten.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Request body — application/json

environment* "sandbox" | "production"
token* string

Answers

200 LiveActivityStartToken The registered push-to-start token.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Answers

204 no body The token, if any, was forgotten.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Request body — application/json

activityId* string
environment* "sandbox" | "production"
token* string

Answers

200 LiveActivityUpdateToken The registered update token.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Path parameters

activityId* string

Answers

204 no body The token, if any, was forgotten.
Problems: 400 401 403 422 — RFC 9457, see Errors.
post /api/v1/timer/start #

Start or switch the caller’s own timer

thymian:timersessionaccess tokenOAuth

Request body — application/json

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 TimerSnapshot The updated timer snapshot.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Answers

200 TimerStopResult The updated timer snapshot and the stopped entry.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:timersessionaccess tokenOAuth

Request body — application/json

platform* "ios" | "watchos"
kind* string
environment* "sandbox" | "production"
token* string

Answers

200 WidgetPushToken The registered widget kind.
Problems: 400 401 403 422 — RFC 9457, see Errors.

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'.

thymian:readsessionaccess tokenOAuth

Query parameters

personId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personIds? string 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33,9a2b4c6d-8e0f-4a1b-9c3d-5e7f9a1b3c5d
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

Answers

200 object The versions. { data: WorkSchedule[] }
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:organization.writesessionaccess tokenno OAuth

Request body — application/json

personId* string (uuid) Whose working time model. Always named — there is no default to the caller, since only an owner or admin may write this at all. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
validFromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
kind* "weekly" | "monthly"
mondaySeconds* integer | null
tuesdaySeconds* integer | null
wednesdaySeconds* integer | null
thursdaySeconds* integer | null
fridaySeconds* integer | null
saturdaySeconds* integer | null
sundaySeconds* integer | null
monthlySeconds* integer | null
note? string | null

Answers

201 WorkSchedule The new version.
Problems: 400 401 403 409 422 — RFC 9457, see Errors.
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.

thymian:organization.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Request body — application/json

validFromDay? string A calendar day, YYYY-MM-DD. 2026-08-14
kind? "weekly" | "monthly"
mondaySeconds? integer | null
tuesdaySeconds? integer | null
wednesdaySeconds? integer | null
thursdaySeconds? integer | null
fridaySeconds? integer | null
saturdaySeconds? integer | null
sundaySeconds? integer | null
monthlySeconds? integer | null
note? string | null

Answers

200 WorkSchedule The corrected version.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.

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.

thymian:invoices.readsessionaccess tokenOAuth

Query parameters

status? "live" | "released" live still holds its seconds; released was taken back. Omitted means both.
limit? integer
cursor? string 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

200 WriteOffList The write-offs.
Problems: 400 401 403 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Request body — application/json

entryIds* string (uuid)[] 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 Why these hours will never be billed. Required: a batch with no reason is exactly the unexplained flag write-offs exist to replace. Projekt nach Vorarbeit abgesagt — Kulanz

Answers

201 CreatedWriteOff The batch, with the claims it wrote.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.writesessionaccess tokenno OAuth

Path parameters

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Answers

200 WriteOff The released write-off.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
post /api/v1/write-offs/allowance #

Write off whole monthly time allowances

thymian:invoices.writesessionaccess tokenno OAuth

Request body — application/json

months* AllowanceWriteOffMonthRequest[] 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 Why these months will never be billed. Required.

Answers

201 CreatedAllowanceWriteOff The batch, with what it waived.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Request body — application/json

months* AllowanceWriteOffMonthRequest[] 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

200 AllowanceWriteOffCut What this selection would waive.
Problems: 400 401 403 404 409 422 — RFC 9457, see Errors.
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.

thymian:invoices.readsessionaccess tokenOAuth

Request body — application/json

entryIds* string (uuid)[] 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

200 WriteOffCut What this selection would waive.
Problems: 400 401 403 404 422 — RFC 9457, see Errors.

Schemas

Every named shape in the document. * is required, ? optional.

Absence

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string Inclusive, and never before fromDay. 2026-08-14
secondsPerDay* integer | null Whole seconds per covered day. null is the whole working day, resolved against the organization’s hoursPerDay when a screen asks. 14400
note* string | null
source* string manual for a person; else a system slug. personio
externalId* string | null The source system’s own id, so a re-import updates this row instead of adding another. Unique per organization and source. personio-absence-4711
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAtPrecise* string

AbsenceList

data* Absence[]
nextCursor* string | null Cursor for the next page, or null when the list is exhausted.

AbsenceLogEntry

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
absenceId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
action* "created" | "updated" | "deleted"
recordedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
actorPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
actorName* string | null
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string A calendar day, YYYY-MM-DD. 2026-08-14
secondsPerDay* integer | null
note* string | null
source* string manual for a person; else a system slug. personio
externalId* string | null
reason* string | null

AbsenceLogHead

absenceId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string A calendar day, YYYY-MM-DD. 2026-08-14
secondsPerDay* integer | null
note* string | null
source* string manual for a person; else a system slug. personio

AbsenceRevision

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
absenceId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
action* "created" | "updated" | "deleted"
position* integer
recordedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
actorPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
actorName* string | null
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string A calendar day, YYYY-MM-DD. 2026-08-14
secondsPerDay* integer | null
note* string | null
source* string manual for a person; else a system slug. personio
externalId* string | null
reason* string | null

AbsenceSubject

id* string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
displayName* string
email* string For mapping against an external system. Always present today.
status* "active" | "deactivated" 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" Always account today: every subject is a Timjami login account. Reserved so a future subject with no login is additive.

AccessRequest

name* string
email* string (email)
company* string
message? string

AccessRequestAccepted

unknown

AccessToken

id* string
name* string | null Reporting script
start* string | null thyp_a1
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")[] What this token was granted, intersected with what its owner may do — a scope never widens a role.
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
expiresAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastRequestAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
requestCount* integer 42

ActivatePersonioAttendanceExport

personioClientId* string The client id of a Personio API credential (Personio: Einstellungen → Integrationen → API-Zugangsdaten).
personioClientSecret* string 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) 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

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
fromMonth* string The month the project joins from: a past, the current, or a future month between the preview’s earliestMonth and latestMonth. 2026-08
createInvoices* boolean 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

id* string (uuid)
name* string
email* string (email)
company* string
message* string | null
status* "pending" | "approved" | "declined"
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
decidedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
inviteCode* string | null The code this request’s approval issued, or null while pending or declined.

AdminAccessRequestList

AdminInviteCode

id* string (uuid)
code* string
label* string
email* string (email) | null
maxUses* integer
uses* integer
expiresAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
revokedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
status* "active" | "used-up" | "expired" | "revoked"
features* OperatorFeature[] The features an organization founded with this code starts with, in catalogue order (ADR-2026-09-30-17KCB).
redemptions* InviteCodeRedemption[]

AdminInviteCodeList

AgentTokenSums

input* integer
output* integer
cacheRead* integer
cacheWrite* integer
reasoning* integer
total* integer 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

object | object | object | object | object

AgentUsageSummary

month* string 2026-10
day* string | null The day the figures are narrowed to, or null for the whole month. 2026-10-02
personId* string (uuid)
totals* AgentTokenSums
bySource* object[] 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" The coding agent a usage key reports for. claude-code
bySource.tokens* AgentTokenSums
byAttribution* object[]
byAttribution.attribution* AgentUsageAttribution
byAttribution.tokens* AgentTokenSums
byModel* object[]
byModel.source* "claude-code" | "codex" The coding agent a usage key reports for. claude-code
byModel.model* string
byModel.tokens* AgentTokenSums
byDay* object[]
byDay.day* string 2026-10-02
byDay.tokens* AgentTokenSums
unattributedPaths* object[] | null Your own unattributed sessions by path. null whenever personId names somebody.
unattributedPaths.source* "claude-code" | "codex" The coding agent a usage key reports for. claude-code
unattributedPaths.pathIds* string[] | null The ids PUT /hint-rules names, parallel to pathLabels.
unattributedPaths.pathLabels* string[] | null
unattributedPaths.sessionCount* integer
unattributedPaths.tokens* AgentTokenSums

AllowanceDivergence

claimId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
invoiceId* string (uuid) 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). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
invoiceStatus* "draft" | "transferred" | "withdrawn"
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string Acme GmbH
customerIcon* object | null
customerIcon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
customerIcon.contentType* "image/webp" | "image/png"
customerIcon.width* integer 256
customerIcon.height* integer 256
customerIcon.byteSize* integer 8452
customerIcon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon.checksum* string a3c9…
allowanceId* string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-07
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personName* string | null
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string Website Relaunch
taskName* string Slack-Begleitung
kinds* ("seconds" | "outside-span")[] 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. ["seconds"]
billed* object What the claim snapshotted when the invoice was created.
billed.seconds* integer What the invoice froze for this month, and what it still bills. Never recomputed (ADR-0037).
billed.currency* string ISO-4217 currency code. EUR
current* object The agreement as it now stands.
current.seconds* integer | null 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 A calendar month, YYYY-MM. 2026-08
current.validUntilMonth* string | null Inclusive. null is an open-ended agreement, so no upper bound at all. 2026-08

AllowanceWriteOff

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
reason* string
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
releasedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
createdByName* string | null
currency* string ISO-4217 currency code. EUR
seconds* integer
amountMinor* integer
monthCount* integer

AllowanceWriteOffCut

currency* string ISO-4217 currency code. EUR
seconds* integer
amountMinor* integer
months* AllowanceWriteOffMonthCut[]

AllowanceWriteOffMonthCut

allowanceId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-08
seconds* integer
personName* string
projectName* string
taskName* string
amountMinor* integer

AllowanceWriteOffMonthRequest

allowanceId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-08

AppInstallationCheckIn

installationId* string (uuid)
app* "desktop" | "cli" | "ios" | "browser-extension" | "raycast"
version* string
platform* string
architecture* string

ApproveAccessRequestRequest

features* OperatorFeature[] The features an organization founded with the code starts with; [] for none. Duplicates are stored once. ["tour","jami"]

ApproveAccessRequestResult

code* string TIMJ-AAAA-AAAA
emailSent* boolean 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

selector* ExternalClaimSelector
invoice* object | null The invoice to attribute these hours to, or null to clear the reference — the undo of a wrong repair.
invoice.harvestInvoiceId* string Harvest’s own invoice id — the segment that identifies the document. Opaque; nothing does arithmetic on it. 19783512
invoice.harvestInvoiceNumber* string | null The number a person recognises, or null for an invoice Harvest gave none. 1042

AssistantTourProgress

tour* "onboarding" | "menu-tabs" 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. onboarding
status* "offered" | "active" | "completed" | "dismissed" 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 Set exactly while status is active. timer
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

AssistantTours

AssistantTourProgress[]

AttachExternalReference

provider* "linear" | "jira" | "azure_devops" | "github" | "generic"
key* string 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. TIM-199
url? string | null 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. https://linear.app/suora/issue/TIM-199
title? string | null A label for the ticket. Trimmed; blank is stored as null.

AuthProviders

google* boolean

BatchColorSuggestionOutcome

object | object

BatchRefusal

type* string
title* string
status* integer The status the single-entry call would have had.
detail? string

BatchSuggestColors

customerIds* string (uuid)[]

BatchSuggestColorsResult

BatchTimeEntryResult

results* object[]
results.entryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
results.status* "done" | "unchanged" | "failed"
results.refusal? BatchRefusal
done* integer
unchanged* integer
failed* integer

BillingAllocation

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "entry" | "allowance" | "item"
timeEntryId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
fromSecond* integer | null
toSecond* integer | null
allowanceId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string | null A calendar month, YYYY-MM. 2026-08
fixedPriceItemId* string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
serviceMonth* string A calendar month, YYYY-MM. 2026-08
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string
manuallyAssigned* boolean
deferReason* string | null
deferredAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
resolved* boolean
settledVia* "invoice" | "external" | "write-off" | null | null
inDraft* boolean 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

allocationId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personName* string
taskName* string
startedAt* string (date-time) | null null for an allowance month and for an item. 2026-07-29T09:00:00.000Z
notes* string | null
fixedPriceItem* object | unknown | null 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 The allowance month’s agreed seconds; null for an entry.
invoiceId* string (uuid) | null The invoice that settled it, or the draft that already claims it; null while open, and for an external claim or a write-off. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

BillingProposal

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
billingRuleId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string
ruleName* string
kind* "periodic" | "catch-up"
periodStartMonth* string 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. 2026-08
periodMonthCount* integer
dueDay* string A calendar day, YYYY-MM-DD. 2026-08-14
selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
firstActionableAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
reopenedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
completedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
state* "actionable" | "nothing-to-bill" | "needs-rate" | "transfer-pending" | "complete" The user-visible condition, composed at read time. needs-rate is reported only on the detail read.
unresolvedSeconds* integer
unresolvedAllowanceMonths* integer
unresolvedFixedPriceItems* integer
invoiceIds? string (uuid)[]
reopened* boolean
allocationCount* integer 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) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

BillingProposalDetail

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
billingRuleId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string
ruleName* string
kind* "periodic" | "catch-up"
periodStartMonth* string 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. 2026-08
periodMonthCount* integer
dueDay* string A calendar day, YYYY-MM-DD. 2026-08-14
selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
firstActionableAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
reopenedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
completedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
state* "actionable" | "nothing-to-bill" | "needs-rate" | "transfer-pending" | "complete" The user-visible condition, composed at read time. needs-rate is reported only on the detail read.
unresolvedSeconds* integer
unresolvedAllowanceMonths* integer
unresolvedFixedPriceItems* integer
invoiceIds? string (uuid)[]
reopened* boolean
allocationCount* integer 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) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
allocations* BillingAllocation[]

BillingProposalListItem

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
billingRuleId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string
ruleName* string
kind* "periodic" | "catch-up"
periodStartMonth* string 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. 2026-08
periodMonthCount* integer
dueDay* string A calendar day, YYYY-MM-DD. 2026-08-14
selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
firstActionableAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
reopenedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
completedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
state* "actionable" | "nothing-to-bill" | "needs-rate" | "transfer-pending" | "complete" The user-visible condition, composed at read time. needs-rate is reported only on the detail read.
unresolvedSeconds* integer
unresolvedAllowanceMonths* integer
unresolvedFixedPriceItems* integer
invoiceIds? string (uuid)[]
reopened* boolean
allocationCount* integer 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) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon* object | null
customerIcon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
customerIcon.contentType* "image/webp" | "image/png"
customerIcon.width* integer 256
customerIcon.height* integer 256
customerIcon.byteSize* integer 8452
customerIcon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon.checksum* string a3c9…

BillingRule

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string
name* string
status* "active" | "paused"
pausedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
projectMode* "selected" | "all" selected names explicit projects; all dynamically covers every project of the customer, including ones created later.
recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
firstPeriodStart* string A calendar month, YYYY-MM. 2026-08
historyMode* "separate-periods" | "collect-history" | "handle-later" 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 A calendar month, YYYY-MM. 2026-08
hasProposals* boolean Whether any proposal has been generated for the rule. Until then it can be deleted; afterwards only paused.
responsiblePersonIds* string (uuid)[] The people who receive due/escalation email for this rule.
coverage* object[]
coverage.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
coverage.projectName* string
coverage.source* "explicit" | "dynamic-all" | "history" 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 A calendar month, YYYY-MM. 2026-08
coverage.validUntilMonth* string | null A calendar month, YYYY-MM. 2026-08
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

BillingRuleListItem

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string
name* string
status* "active" | "paused"
pausedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
projectMode* "selected" | "all" selected names explicit projects; all dynamically covers every project of the customer, including ones created later.
recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
firstPeriodStart* string A calendar month, YYYY-MM. 2026-08
historyMode* "separate-periods" | "collect-history" | "handle-later" 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 A calendar month, YYYY-MM. 2026-08
hasProposals* boolean Whether any proposal has been generated for the rule. Until then it can be deleted; afterwards only paused.
responsiblePersonIds* string (uuid)[] The people who receive due/escalation email for this rule.
coverage* object[]
coverage.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
coverage.projectName* string
coverage.source* "explicit" | "dynamic-all" | "history" 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 A calendar month, YYYY-MM. 2026-08
coverage.validUntilMonth* string | null A calendar month, YYYY-MM. 2026-08
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon* object | null
customerIcon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
customerIcon.contentType* "image/webp" | "image/png"
customerIcon.width* integer 256
customerIcon.height* integer 256
customerIcon.byteSize* integer 8452
customerIcon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon.checksum* string a3c9…
nextPeriod* object | null The period the rule proposes next: the one still running, or its first if that lies ahead. null while paused.
nextPeriod.periodStartMonth* string A calendar month, YYYY-MM. 2026-08
nextPeriod.periodMonthCount* integer
nextPeriod.dueDay* string A calendar day, YYYY-MM-DD. 2026-08-14
latestProposal* object | null The proposal whose period ends last, or null before the first one.
latestProposal.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
latestProposal.kind* "periodic" | "catch-up"
latestProposal.periodStartMonth* string A calendar month, YYYY-MM. 2026-08
latestProposal.periodMonthCount* integer
latestProposal.state* "actionable" | "nothing-to-bill" | "needs-rate" | "transfer-pending" | "complete" The user-visible condition, composed at read time. needs-rate is reported only on the detail read.
latestProposal.allocationCount* integer

BillingRulePeriodPreview

currency* string
periods* PricedPreviewPeriod[]
history* PricedPreviewHistory[] 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

task* object
task.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
task.name* string
task.displayName* string Server-selected task label, composed from displayNameParts in the same priority order. Acme GmbH · Frontend
task.displayNameParts* string[] One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. ["Acme GmbH","Frontend"]
task.isBillable* boolean
task.icon* ResourceIcon
task.iconInheritedFrom* VisualProvenance
project* object
project.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project.name* string
project.color* string | null #228be6
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
searchText* string 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. Acme GmbH ACME Website Relaunch Frontend

BreakDismissal

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

BreakPeriod

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

Budget

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string Acme GmbH
name* string Named by a person: a budget outlives the composition it started with. Acme Retainer
validFromMonth* string A calendar month, YYYY-MM. 2026-08
validUntilMonth* string | null Inclusive end. null is open-ended; set when the budget is superseded. 2026-08
budgetedSeconds* integer 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. 540000
kind* "monthly" | "total" 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. monthly
overflowIntoId* string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
overflowIntoName* string | null The overflow target’s name, so a screen needs no second lookup. Server-Umzug
projects* object[]
projects.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.projectName* string
projects.archived* boolean
archivedAt* string (date-time) | null Hidden from lists. Different from validUntilMonth, which means "was superseded". 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

BudgetAdjustment

budgetId* string (uuid) The monthly budget whose adjustment is being changed. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string The single calendar month to adjust, formatted YYYY-MM; the budget must cover it. 2026-08
adjustmentSeconds* integer 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. 46800
note* string | null Optional explanation for why this month differs. Free text; null clears the note. Übertrag aus Juli

BudgetAlerts

budgetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
availableScopes* ("month" | "total")[] 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[]

BudgetAlertThreshold

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
metric* "consumption" What is measured. Only the budget’s own consumption today; a forecast threshold would be a second value here.
scope* "month" | "total" 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. month
percent* integer 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. 80
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
triggers* BudgetAlertTrigger[] 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string | null The month that was watched, or null for a total-scope threshold. 2026-08
firedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
resolvedAt* string (date-time) | null 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. 2026-07-29T09:00:00.000Z
consumedSeconds* integer What had been consumed when the threshold was reached.
budgetedSeconds* integer What was agreed for the watched window.
adjustmentSeconds* integer 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

month* string A calendar month, YYYY-MM. 2026-08
budgets* object[]
budgets.budgetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.name* string
budgets.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.customerName* string
budgets.kind* "monthly" | "total" 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. monthly
budgets.validFromMonth* string A calendar month, YYYY-MM. 2026-08
budgets.validUntilMonth* string | null The span, so a screen can label what a total figure covers. 2026-08
budgets.overflowIntoId* string (uuid) | null Where hours beyond capacity count instead, when declared. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.overflowIntoName* string | null
budgets.budgetedSeconds* integer What was agreed, without this month’s adjustment.
budgets.adjustmentSeconds* integer 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. 46800
budgets.adjustmentNote* string | null Why this month was adjusted, when somebody said. Übertrag aus Juli
budgets.consumedSeconds* integer
budgets.differenceSeconds* integer Consumed minus budgeted and this month’s adjustment. Positive means over. -18000
budgets.consumedPercent* integer | null 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. 81
budgets.projects* object[]
budgets.overlapping* boolean One of its projects is also in another budget this month, with no overflow order between the two.
unbudgeted* object[]
unbudgeted.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
unbudgeted.projectName* string
unbudgeted.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
unbudgeted.customerName* string
unbudgeted.consumedSeconds* integer
overlaps* object[]
overlaps.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
overlaps.projectName* string
overlaps.budgetIds* string (uuid)[]

BudgetReportChannel

id* string
name* string
isPrivate* boolean
isMember* boolean Whether the Timjami app is in the channel. A public channel works either way; a private one only once the app was invited.

BudgetReports

subscriptions* BudgetReportSubscription[]
slackConnected* boolean Whether this organization has a Slack workspace connected to post into.
canManageSlack* boolean Whether the caller may add, reschedule or remove Slack reports of this budget (budget.write).

BudgetReportSchedule

sendWeekdays* integer[] 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. [1,4]
sendHour* integer 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. 8

BudgetReportSubscription

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
channel* "email" | "slack" 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
slackChannelName* string | null The channel’s name when it was chosen — a label, never used to address the post.
createdByName* string 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[] 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. [1,4]
sendHour* integer 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. 8
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
latestDelivery* object | null
latestDelivery.occurrenceDay* string A calendar day, YYYY-MM-DD. 2026-08-14
latestDelivery.sentAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
latestDelivery.lastError* string | null 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

month* string The organization’s current month. 2026-08
today* string Today, in the organization’s time zone. 2026-08-14
monthWorkdays* integer Monday-to-Friday days in month.
elapsedMonthWorkdays* integer Monday-to-Friday days in month up to and including today.
budgets* BudgetTrajectory[]

BudgetTrajectory

budgetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "monthly" | "total" 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. monthly
periodFirstDay* string The month for monthly; the whole span for total. 2026-08-14
periodLastDay* string A calendar day, YYYY-MM-DD. 2026-08-14
basis* "plan" | "pace" 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" 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
elapsedWorkdays* integer Today included.
capacitySeconds* integer Agreed plus this month’s adjustment, as in differenceSeconds.
consumedSeconds* integer
targetSeconds* integer Capacity × elapsed working days ÷ the period’s working days.
remainingPlannedSeconds* integer | null The shared forecast’s remaining planned work; null for pace.
pacePerWorkdaySeconds* integer
forecastSeconds* integer
deviationSeconds* integer Forecast minus capacity. Positive runs out, negative is left over.
toleranceSeconds* integer The larger of 10 % of capacity and eight hours.
exhaustsOnDay* string | null For running-out: the working day the forecast reaches capacity. Null when it already has. 2026-08-14
requiredPerWorkdaySeconds* integer | null For left-over: seconds per remaining working day that would use the rest.

BudgetTrajectoryDetail

month* string A calendar month, YYYY-MM. 2026-08
today* string A calendar day, YYYY-MM-DD. 2026-08-14
trajectory* BudgetTrajectory
days* object[] Every working day (Monday to Friday) of the period.
days.day* string A calendar day, YYYY-MM-DD. 2026-08-14
days.targetSeconds* integer Capacity spread evenly over the working days, up to this one.
days.consumedSeconds* integer | null 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 The forecast from today to the end of the period at its own daily rate; null before today and while too-early.

CapacityAgreement

id* string (uuid)
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
validFromDay* string Inclusive first day this version holds. The next version ends it. 2026-08-14
kind* "share" | "fixed" 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 kind=share only: percentage of contracted time sold, "0.00" to "100.00". 100.00
soldSecondsPerWeek* integer | null kind=fixed only: the sold week itself, in whole seconds. 72000
employment* "internal" | "external" internal (employed) or external (freelancer).
inTeamMetric* boolean Whether this person’s hours are in the group whose billable quota is measured.
note* string | null
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

CapacityAgreements

agreements* CapacityAgreement[]

ChangeBillingRuleCoverage

projectMode* "selected" | "all" selected names explicit projects; all dynamically covers every project of the customer, including ones created later.
projectIds* string (uuid)[] Required, non-empty, when projectMode is selected; must be empty for all.
effectiveFromPeriod* string A future period. Existing proposals before it keep their prior coverage. 2026-08

ChangePersonioPersonMapping

personioPersonId* string (uuid) | null The Personio mapping item to confirm, or null to revoke.
expectedAssignment* object | null The assignment and version displayed to the administrator, or null for an unassigned person.
expectedAssignment.id* string
expectedAssignment.version* integer

CollectorKey

id* string
name* string | null Laptop
start* string | null thy_a1
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastRequestAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
requestCount* integer 42

ColorSuggestion

hex* string #228be6
name* "Vibrant" | "DarkVibrant" | "LightVibrant" | "Muted" | "DarkMuted" | "LightMuted" node-vibrant's own swatch name — stable, not translated.
population* integer How many pixels of the icon this swatch covers; the ranking key.

ColorSuggestions

suggestions* ColorSuggestion[]
best* ColorSuggestion | unknown | null

CompanyMetricMonth

month* string A calendar month, YYYY-MM. 2026-08
fromDay* string The period's first day, organization-local. 2026-08-01
untilDay* string The period's last day, inclusive. 2026-08-31
complete* boolean 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 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 Percent of the measured group's tracked seconds on catch-all tasks, or null where it tracked nothing.
billableRate* number | null Percent of the measured group's tracked seconds that is value-creating — invoiceable work plus productive work the organization cannot invoice.
billableRateDeviation* number | null 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 Minor units of recorded revenue per FTE of this period’s capacity. Counts every internal person and excludes freelancers.
revenueMinor? integer The undivided total the figure above comes from, in minor units. Absent below report.readOthers.
targetRevenueMinor? integer | null 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 revenueMinor minus targetRevenueMinor — positive above the target, negative below it. Null without targets.
measuredSeconds? integer The measured group's tracked seconds, the denominator both rates share. Absent below report.readOthers.
warnings? object 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 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)[] People with tracked time in the period this read covers and no classification. They count in no figure.
warnings.withoutSchedule* string (uuid)[] 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)[] 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

currency* string ISO-4217 currency code. EUR
targets* object | null 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
targets.billableRateMax* number
targets.revenuePerFteMinor* integer
months* CompanyMetricMonth[] Newest first, the running month included and marked complete: false. One row per calendar month.

CompanyMetrics

window* object The four finished weeks the figures cover. Both bounds inclusive, organization-local.
window.fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
window.untilDay* string A calendar day, YYYY-MM-DD. 2026-08-14
previousWindow* object The same window shifted back one week — what the previous post covered.
previousWindow.fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
previousWindow.untilDay* string A calendar day, YYYY-MM-DD. 2026-08-14
fte? number 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 ISO-4217 currency code. EUR
catchAllRate* object Percent of the measured group's tracked seconds on tasks flagged as the catch-all.
catchAllRate.value* number | null
catchAllRate.previous* number | null
catchAllRate.trend* "up" | "down" | "flat" | null | null
billableRate* object Percent of the measured group's tracked seconds on billable tasks.
billableRate.value* number | null
billableRate.previous* number | null
billableRate.trend* "up" | "down" | "flat" | null | null
revenuePerFte* object Minor units of recorded revenue per FTE of sold capacity. Counts every internal person and excludes freelancers.
revenuePerFte.value* number | null
revenuePerFte.previous* number | null
revenuePerFte.trend* "up" | "down" | "flat" | null | null
targets* object | null 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
targets.billableRateMax* number
targets.revenuePerFteMinor* integer
warnings? object 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 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)[] People with tracked time in the period this read covers and no classification. They count in no figure.
warnings.withoutSchedule* string (uuid)[] 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)[] 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

weekStartDay* string 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. 2026-08-10
fromDay* string The period's first day, organization-local. 2026-08-10
untilDay* string The period's last day, inclusive. 2026-08-16
complete* boolean 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 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 Percent of the measured group's tracked seconds on catch-all tasks, or null where it tracked nothing.
billableRate* number | null Percent of the measured group's tracked seconds that is value-creating — invoiceable work plus productive work the organization cannot invoice.
billableRateDeviation* number | null 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 Minor units of recorded revenue per FTE of this period’s capacity. Counts every internal person and excludes freelancers.
revenueMinor? integer The undivided total the figure above comes from, in minor units. Absent below report.readOthers.
targetRevenueMinor? integer | null 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 revenueMinor minus targetRevenueMinor — positive above the target, negative below it. Null without targets.
measuredSeconds? integer The measured group's tracked seconds, the denominator both rates share. Absent below report.readOthers.
warnings? object 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 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)[] People with tracked time in the period this read covers and no classification. They count in no figure.
warnings.withoutSchedule* string (uuid)[] 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)[] 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

currency* string ISO-4217 currency code. EUR
targets* object | null 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
targets.billableRateMax* number
targets.revenuePerFteMinor* integer
weeks* CompanyMetricWeek[] Newest first, the running week included and marked complete: false. One row per whole week.

ConnectEasybill

apiKey* string Easybill's Bearer API key, from the account's own settings.
easybillServicePositionId? string The Easybill article's id. 236898474

ConnectHarvest

accountId* string Harvest's numeric account id, from the account's own settings. 1234567
accessToken* string A personal access token, from the account's own settings.
confirmDifferentEmail? boolean

ConnectSevdesk

apiToken* string The account's sevdesk API token, from the settings of the sevdesk user.
servicePartId? string sevdesk's own id. 900000007
expensePartId? string sevdesk's own id. 900000007
contactPersonId? string sevdesk's own id. 900000007
taxRuleId? 1 | 11 The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1

CreateAbsence

personId? string (uuid) Whose absence. Defaults to the caller; naming somebody else needs owner or admin. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string Inclusive. Equal to fromDay for a single day. 2026-08-14
secondsPerDay? integer | null 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 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. 0.5
note? string | null
source? string Defaults to manual. personio
externalId? string | null
editReason? string 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

name* string How this token is labelled in the list, e.g. what uses it. Reporting script
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")[] What the token may do. Required and never defaulted: a token whose permissions nobody chose is the one nobody can reason about later. ["thymian:read"]
expiresInDays? integer Optional lifetime in days. Omit for a token that does not expire — revoking it is then the only way it stops working. 90

CreateAllowanceWriteOff

months* AllowanceWriteOffMonthRequest[] 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 Why these months will never be billed. Required.

CreateBillingRule

customerId* string (uuid) The customer this rule bills. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string A human-readable name for the rule.
projectMode* "selected" | "all" selected names explicit projects; all dynamically covers every project of the customer, including ones created later.
projectIds* string (uuid)[] Required, non-empty, when projectMode is selected; must be empty for all.
recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
firstPeriodStart* string Normalized to a quarter start when recurrence is quarterly. 2026-08
historyMode* "separate-periods" | "collect-history" | "handle-later" 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 Only meaningful, and required, when historyMode is collect-history. 2026-08
responsiblePersonIds* string (uuid)[] The people who receive due/escalation email for this rule.

CreateBudget

customerId* string (uuid) The customer that owns this budget. Every projectIds entry and referenced overflow budget must belong to this customer. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string A human-readable name for the agreement; it remains stable when the project composition changes. Acme Retainer
validFromMonth* string The first calendar month covered by the budget, inclusive. 2026-08
validUntilMonth* string | null The last calendar month covered by the budget, inclusive. null keeps it open-ended. 2026-08
budgetedSeconds* integer 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. 540000
kind? "monthly" | "total" 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. monthly
overflowIntoId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
overflowFromId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds* string (uuid)[] One or more project IDs covered by the budget. Every project must belong to customerId.

CreateBudgetAlert

scope* "month" | "total" 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. month
percent* integer 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. 80

CreateBudgetReportSlack

slackChannelId* string The Slack channel id, as GET /budgets/report-channels lists it.
sendWeekdays* integer[] 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. [1,4]
sendHour* integer 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. 8

CreateCapacityAgreement

object | object

CreateCollectorKey

name? string How this key is labelled in the list, e.g. the machine it lives on. Laptop

CreateCustomer

name* string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
code? string | null
color? string #228be6

CreatedAccessToken

id* string
name* string | null Reporting script
start* string | null thyp_a1
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")[] What this token was granted, intersected with what its owner may do — a scope never widens a role.
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
expiresAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastRequestAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
requestCount* integer 42
token* string thyp_9f8c…

CreatedAllowanceWriteOff

CreateDashboard

name* string
visibility? "private" | "shared" 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

CreatedCollectorKey

id* string
name* string | null Laptop
start* string | null thy_a1
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastRequestAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
requestCount* integer 42
key* string thy_9f8c…

CreatedProjectAllowanceWriteOff

writeOffId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
cut* AllowanceWriteOffCut

CreatedProjectEntriesWriteOff

writeOffId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
cut* WriteOffCut

CreatedProjectWriteOff

CreatedUsageKey

id* string
name* string | null pasture
start* string | null thyu_a1
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastRequestAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
source* "claude-code" | "codex" The coding agent a usage key reports for. claude-code
device* string pasture
key* string The key itself. Shown once; only a hash is stored.

CreatedWriteOff

writeOff* WriteOff
cut* WriteOffCut

CreateFixedPriceAgreement

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string

CreateFixedPriceItem

agreementId? string (uuid) | null Required for a service; omit or null for a standalone expense. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "service" | "expense" 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
quantityHundredths* integer
unit* string
unitPriceMinor* integer
days* string[]
people? object[] 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) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
people.weight* integer The share of the item’s revenue, relative to the other people. Equal by default. 1
easybillPositionId? string | null 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. 236898474
editReason? string 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

features* OperatorFeature[] The features an organization founded with the code starts with; [] for none. Duplicates are stored once. ["tour","jami"]
label* string
email? string (email)
maxUses* integer
expiresAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

CreateInvoice

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string (uuid)[]
budgetIds? string (uuid)[] 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)[] 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 Inclusive lower bound on the entry start, or null for no lower bound. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on the entry start. 2026-07-29T09:00:00.000Z
grouping* "member" | "project" | "task" | "entry" 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. project
notes? string | null Free text for the invoice. Never filled from a time entry’s notes, which may be internal.

CreateProject

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
rules? WriteRateRule[]
visibility? "public" | "private" Who may see the project. public is the whole organization.
color? string | null #228be6

CreateProjectWriteOff

from? string Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. 2026-08-14
until? string Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. 2026-08-14
reason* string Why these hours and months will never be billed. Required, for the same reason CreateWriteOff.reason is. Projekt nach Vorarbeit abgesagt — Kulanz

CreateProposalInvoice

notes? string | null
grouping? "member" | "project" | "task" | "entry" Overrides the proposal’s own default grouping for this one invoice.

CreateTask

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string
billingMode? "hourly" | "fixed_price" | "productive" | "internal" 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 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
isBillabledeprecated? boolean 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.
isProductivedeprecated? boolean 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 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

personId* string (uuid) Who the allowance is for. Must be a person of the active organization who still holds a membership. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId* string (uuid) 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). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
secondsPerMonth* integer The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. 7200
validFromMonth* string 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. 2026-09
validUntilMonth* string | null 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. null
note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

CreateTimeEntry

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
notes? string | null
editReason? string 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

source* "claude-code" | "codex" The coding agent a usage key reports for. claude-code
device* string The machine the agent runs on, as the key is labelled in the list. pasture

CreateWorkSchedule

personId* string (uuid) Whose working time model. Always named — there is no default to the caller, since only an owner or admin may write this at all. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
validFromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
kind* "weekly" | "monthly"
mondaySeconds* integer | null
tuesdaySeconds* integer | null
wednesdaySeconds* integer | null
thursdaySeconds* integer | null
fridaySeconds* integer | null
saturdaySeconds* integer | null
sundaySeconds* integer | null
monthlySeconds* integer | null
note? string | null

CreateWriteOff

entryIds* string (uuid)[] 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 Why these hours will never be billed. Required: a batch with no reason is exactly the unexplained flag write-offs exist to replace. Projekt nach Vorarbeit abgesagt — Kulanz

Customer

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string Acme GmbH
aliases* string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
code* string | null ACME
color* string #228be6
colorSource* "default" | "suggested" | "manual" 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
archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

CustomerList

data* Customer & object[]
nextCursor* string | null Cursor for the next page, or null when the list is exhausted.

CustomerSetup

customer* object
customer.name* string
customer.aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
customer.code? string | null
easybill? object 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 4711
project? object The first project.
project.name* string
project.aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
project.visibility? "public" | "private" Who may see the project. public is the whole organization.
project.hourlyRateMinor? integer The project’s default hourly rate in minor units, as one rule from the beginning. Not together with rateTemplateProjectId. 9900
project.rateTemplateProjectId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project.tasks? object[] The tasks to create. No two may share a name, compared case-insensitively.
project.personIds? string (uuid)[] The people to assign. You are assigned anyway, named here or not.
billing? object 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 A human-readable name for the rule.
billing.recurrence* "monthly" | "quarterly" How often a period elapses. A quarterly rule’s periods align to Jan/Apr/Jul/Oct.
billing.dueDayOfMonth* integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
billing.selectionMode* "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
billing.firstPeriodStart? string 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. 2026-08
billing.responsiblePersonIds? string (uuid)[] The people reminded when a proposal is due. Defaults to you.

CustomerSetupResult

customer* Customer
project* Project | unknown | null
billingRule* BillingRule | unknown | null
easybill* EasybillCustomerMapping & object | object | unknown | null null when the request named no Easybill customer.
droppedRateRules* DroppedRateRule[]

CustomerSetupTemplate

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
visibility* "public" | "private" Who may see the project. public is the whole organization.
tasks* object[] The project’s tasks that are not archived, by name.
tasks.name* string
tasks.billingMode* "hourly" | "fixed_price" | "productive" | "internal" 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.isBillabledeprecated* boolean 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.isProductivedeprecated* boolean 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 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)[] 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string
visibility* "private" | "shared" 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
ownerPersonId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
ownerName* string The author’s display name, so a shared dashboard says whose it is.
isOwn* boolean Whether the caller owns it — and therefore whether they may change it.
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

DashboardLayout

widgets* DashboardWidgetPlacement[] The placements, in the order they were composed. At most 100, each with an id no other placement in this layout uses.

Dashboards

dashboards* Dashboard[]

DashboardWidgetPerson

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string
avatarUrl* string | null The final profile picture URL: the person's Timjami avatar, otherwise the identity provider's picture.
scheduledSeconds* integer The work schedule demand before absences are applied.
absentSeconds* integer
expectedSeconds* integer Today's target after absences are applied.
trackedSeconds* integer Tracked seconds including a running timer measured at asOf.
billableSeconds* integer Of trackedSeconds, the seconds on billable tasks — task billability, not whether a rate resolved.
hasAbsence* boolean
isRunning* boolean
tasks* object[]
tasks.taskId* string (uuid)
tasks.taskName* string
tasks.projectId* string (uuid)
tasks.projectName* string
tasks.customerId* string (uuid)
tasks.customerName* string
tasks.trackedSeconds* integer
tasks.isRunning* boolean

DashboardWidgetPlacement

id* string Client-assigned identity of this placement, unique within the layout. billable-hours-1
widget* string Which widget to render. Opaque to the server — the web app resolves it against its own catalogue and skips what it does not know. billable-hours
x* integer
y* integer
w* integer
h* integer
settings? object 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. {"period":"month","compare":true}
title? string A title chosen for this placement, shown instead of the widget’s own. Umsatz Kunde A

DashboardWidgetResult

object | object | object | object | object | object | object | object | object | object | object | object | object | object | object

DashboardWidgets

asOf* string (date-time) The single instant running timers were measured at. 2026-07-29T09:00:00.000Z
day* string A calendar day, YYYY-MM-DD. 2026-08-14
currency* string ISO-4217 currency code. EUR
results* DashboardWidgetResult[] One result per request, in request order.

DashboardWidgetsRequest

day? string The organization-local day the periods are read for. Defaults to today in the organization's time zone. 2026-08-14
requests* (object | object | object | object | object | object | object | object | object | object | object | object | object | object | object)[]

DayProgress

day* string A calendar day, YYYY-MM-DD. 2026-08-14
trackedSeconds* integer Finished seconds only — the same rule every other figure here follows.
targetSeconds* integer What this one day was expected to hold, alone — zero for a day nobody works or one an absence covers in full.

DeletedTimeEntry

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
recordedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
actorPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
actorName* string | null
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
notes* string | null
taskName* string | null
projectName* string | null
customerName* string | null

DeleteTimeEntries

entryIds* string (uuid)[]
editReason? string 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

deleted* DemoDataCounts
preserved* DemoDataCounts

DemoDataCounts

customers* integer
projects* integer
tasks* integer
timeEntries* integer

DemoDataStatus

exists* boolean
createdAt* string (date-time) | null
counts* DemoDataCounts
canManage* boolean

DismissHint

dismissed* boolean true = not worth tracking. false takes the dismissal back.

Divergence

claimId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "invoice" | "external" | "write-off" 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. invoice
invoiceId* string (uuid) | null The Timjami invoice this claim belongs to — null for every other kind, which is the same statement as kind !== "invoice". 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
invoiceStatus* "draft" | "transferred" | "withdrawn" | null | null
externalDocumentRef* string | null What the other system calls the document these hours were billed on — for Harvest, harvest:invoice:<id>:<number>. null for every other kind, and for an external claim whose source named no document. harvest:invoice:13150403:1001
customerName* string Acme GmbH
customerIcon* object | null
customerIcon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
customerIcon.contentType* "image/webp" | "image/png"
customerIcon.width* integer 256
customerIcon.height* integer 256
customerIcon.byteSize* integer 8452
customerIcon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon.checksum* string a3c9…
customerId* string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
entryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string Website Relaunch
taskName* string Frontend
kinds* ("window" | "task" | "person" | "rate" | "partly-unpriced" | "currency" | "over-billed")[] 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. ["window"]
effect* "billing" | "relocation" 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. billing
billed* object What the claim snapshotted when the invoice was created.
billed.startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
billed.endedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
billed.personId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
billed.rateMinor* integer | null 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 ISO-4217 currency code. EUR
billed.claimedSeconds* integer
billed.customerId* string (uuid) | null 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". 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
billed.customerName* string | null
current* object The entry as it now stands.
current.startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
current.endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
current.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
current.rateMinor* integer | null 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 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
current.currency* string ISO-4217 currency code. EUR
current.durationSeconds* integer
current.customerId* string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
current.customerName* string Beispiel GmbH
override* object | null 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 Doppelbuchung, Stunden auf das richtige Projekt
override.recordedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
override.actorName* string | null

DroppedRateRule

reason* "budget" | "task" | "person" 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
personId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personName* string | null
budgetId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetName* string | null
hourlyRateMinor* integer

EasybillConnection

configured* boolean
easybillServicePositionId* string | null 236898474
easybillServiceLabel* string | null 1 · Softwareentwicklung
easybillExpensePositionId* string | null 236898475
easybillExpenseLabel* string | null 2 · Reisekosten
connectedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

EasybillCustomerList

data* object[]
data.id* string 4711
data.number* string | null 10023
data.companyName* string | null Muster GmbH
data.firstName* string | null Anna
data.lastName* string | null Muster
data.zipCode* string | null 20095
data.city* string | null Hamburg
page* integer 1
pages* integer 1

EasybillCustomerMapping

easybillCustomerId* string 4711
easybillCustomerName* string | null Muster GmbH

EasybillCustomerMappingList

data* EasybillCustomerMapping & object[]

EasybillOutstanding

openMinor* integer 1428500
openCount* integer 7
overdueMinor* integer 357000
overdueCount* integer 2
withheldCount* integer 0
oldestFetchedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

EasybillServiceList

data* object[]
data.id* string 236898474
data.number* string | null 1
data.description* string | null Softwareentwicklung
data.unit* string | null Std.
data.vatPercent* number | null 19
page* integer 1
pages* integer 1

EntryWritePolicy

times* "free" | "reason" | "locked" startedAt and endedAt.
task* "free" | "reason" | "locked" 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" Always free: a note was never part of what a document claims.
remove* "free" | "reason" | "locked" 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[] Empty exactly when every field above is free.

EvaluateHints

hintIds* string (uuid)[] This person’s own hints to evaluate — up to 50 per request.

ExportDocumentInput

documentId* string The document's id in the invoicing tool the export went to.

ExportedInvoice

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
status* "draft" | "transferred" | "withdrawn" '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. draft
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
grouping* "member" | "project" | "task" | "entry" 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. project
periodStart* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
periodEnd* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
currency* string ISO-4217 currency code. EUR
subtotalMinor* integer 372500
notes* string | null
transferredAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
withdrawnAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
export* InvoiceExport
transferredByHand* boolean
document* InvoiceDocument
recordedDocument* InvoiceRecordedDocument
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAtPrecise* string
servicePeriodStart* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
servicePeriodEnd* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lines* InvoiceLine[]
projects* object[]
projects.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.name* string
budgets* object[]
budgets.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.name* string
tasks* object[]
tasks.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
tasks.name* string
tasks.projectName* string
exportNotice* string | null 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. Der Rechnungsentwurf liegt in sevdesk, weicht aber ab: Netto in sevdesk 1652.37 EUR, in Timjami 1652.38 EUR.

ExternalClaimRepair

claims* integer How many claims were written.
entries* integer How many time entries they belong to.
seconds* integer The seconds those claims cover — summed from what was written, not from the request.

ExternalClaimSelector

documentRef* string | null 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. null
projectId? string (uuid) Narrows to one project inside that document — the row under it. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

ExternallyBilledCustomer

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string Acme GmbH
icon* object | null
icon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
icon.contentType* "image/webp" | "image/png"
icon.width* integer 256
icon.height* integer 256
icon.byteSize* integer 8452
icon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
icon.checksum* string a3c9…
archived* boolean
projects* ExternallyBilledProjectRollup[]
seconds* integer Every second a live external claim settles at this level — pricedSeconds + unratedSeconds + otherCurrencySeconds. 36000
pricedSeconds* integer The seconds amountMinor is built from: the other system stated a rate, in the organization’s current currency. 32400
unratedSeconds* integer 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. 3600
otherCurrencySeconds* integer 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. 0
amountMinor* integer 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. 480000
entryCount* integer How many time entries contribute. An entry is counted once. 42
billedFrom* string (date-time) Start of the earliest billed second. 2026-07-29T09:00:00.000Z
billedUntil* string (date-time) End of the latest billed second. 2026-07-29T09:00:00.000Z

ExternallyBilledDocument

documentRef* string | null The other system’s document, verbatim: harvest:invoice:<id>:<number>. 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. harvest:invoice:19783512:1042
claimCount* integer How many claims the document holds. More than one per entry is the ordinary extended-entry case, not a duplicate.
rates* ExternallyBilledRate[] 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[] 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[] The projects the document billed, most hours first.
seconds* integer Every second a live external claim settles at this level — pricedSeconds + unratedSeconds + otherCurrencySeconds. 36000
pricedSeconds* integer The seconds amountMinor is built from: the other system stated a rate, in the organization’s current currency. 32400
unratedSeconds* integer 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. 3600
otherCurrencySeconds* integer 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. 0
amountMinor* integer 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. 480000
entryCount* integer How many time entries contribute. An entry is counted once. 42
billedFrom* string (date-time) Start of the earliest billed second. 2026-07-29T09:00:00.000Z
billedUntil* string (date-time) End of the latest billed second. 2026-07-29T09:00:00.000Z

ExternallyBilledPerson

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personName* string Max Mustermann
seconds* integer

ExternallyBilledProject

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string Website Relaunch
archived* boolean 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string Acme GmbH
customerArchived* boolean
seconds* integer Every second a live external claim settles at this level — pricedSeconds + unratedSeconds + otherCurrencySeconds. 36000
pricedSeconds* integer The seconds amountMinor is built from: the other system stated a rate, in the organization’s current currency. 32400
unratedSeconds* integer 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. 3600
otherCurrencySeconds* integer 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. 0
amountMinor* integer 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. 480000
entryCount* integer How many time entries contribute. An entry is counted once. 42
billedFrom* string (date-time) Start of the earliest billed second. 2026-07-29T09:00:00.000Z
billedUntil* string (date-time) End of the latest billed second. 2026-07-29T09:00:00.000Z
customerIcon* object | null
customerIcon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
customerIcon.contentType* "image/webp" | "image/png"
customerIcon.width* integer 256
customerIcon.height* integer 256
customerIcon.byteSize* integer 8452
customerIcon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon.checksum* string a3c9…

ExternallyBilledProjectRollup

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string Website Relaunch
archived* boolean 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string Acme GmbH
customerArchived* boolean
seconds* integer Every second a live external claim settles at this level — pricedSeconds + unratedSeconds + otherCurrencySeconds. 36000
pricedSeconds* integer The seconds amountMinor is built from: the other system stated a rate, in the organization’s current currency. 32400
unratedSeconds* integer 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. 3600
otherCurrencySeconds* integer 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. 0
amountMinor* integer 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. 480000
entryCount* integer How many time entries contribute. An entry is counted once. 42
billedFrom* string (date-time) Start of the earliest billed second. 2026-07-29T09:00:00.000Z
billedUntil* string (date-time) End of the latest billed second. 2026-07-29T09:00:00.000Z
documentRefs* (string | null)[] The documents this project’s hours sit on, one entry per document, null included — the answer to „auf welchen Rechnungen steht dieses Projekt".

ExternallyBilledRate

rateMinor* integer | null What the other system billed per hour, or null where it stated no rate.
currency* string ISO-4217 currency code. EUR
seconds* integer

ExternallyBilledSummary

currency* string ISO-4217 currency code. EUR
total* ExternallyBilledTotal
documents* ExternallyBilledDocument[] 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[] The same seconds by customer and project, most hours first.

ExternallyBilledTotal

seconds* integer Every second a live external claim settles at this level — pricedSeconds + unratedSeconds + otherCurrencySeconds. 36000
pricedSeconds* integer The seconds amountMinor is built from: the other system stated a rate, in the organization’s current currency. 32400
unratedSeconds* integer 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. 3600
otherCurrencySeconds* integer 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. 0
amountMinor* integer 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. 480000
entryCount* integer How many time entries contribute. An entry is counted once. 42
documentCount* integer Distinct documents, counting the „no document" group as one where it has hours.
withoutDocument* object 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 Every second a live external claim settles at this level — pricedSeconds + unratedSeconds + otherCurrencySeconds. 36000
withoutDocument.pricedSeconds* integer The seconds amountMinor is built from: the other system stated a rate, in the organization’s current currency. 32400
withoutDocument.unratedSeconds* integer 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. 3600
withoutDocument.otherCurrencySeconds* integer 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. 0
withoutDocument.amountMinor* integer 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. 480000
withoutDocument.entryCount* integer How many time entries contribute. An entry is counted once. 42

ExternalReference

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
provider* "linear" | "jira" | "azure_devops" | "github" | "generic" 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. linear
key* string The ticket’s identifier in that system, as that system writes it. TIM-199
url* string | null Where the ticket lives. Always https://, and null where nobody supplied one. https://linear.app/suora/issue/TIM-199
title* string | null The ticket’s title as it read when this was attached — a label, never a cache. It is allowed to go stale. External ticket references on time entries
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Feature

"harvest" | "company-metrics" | "agent-usage" | "day-export" | "tour" | "jami" | "jami-notes" | "max-view" | "entry-split" | "planning" | "customer-portal" | "tickets" | "absences"

FixedPriceAgreement

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string React-Workshop Okt 2026
tasks* object[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
tasks.name* string
tasks.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
tasks.billingMode* "hourly" | "fixed_price" | "productive" | "internal" 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 ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
items* FixedPriceItem[]
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

FixedPriceControlling

agreementId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
currency* string The organization’s currency; every amount here is in it.
from* string | null A calendar day, YYYY-MM-DD. 2026-08-14
until* string | null A calendar day, YYYY-MM-DD. 2026-08-14
hours* object Finished hours of everybody who worked on the agreement, split by kind.
hours.fixedPriceSeconds* integer Hours on the agreement’s fixed_price tasks — the sold work.
hours.investmentSeconds* integer Hours on the agreement’s productive and internal tasks — our own investment around it.
hours.totalSeconds* integer
people* object[] The same split per person, most hours first.
people.fixedPriceSeconds* integer Hours on the agreement’s fixed_price tasks — the sold work.
people.investmentSeconds* integer Hours on the agreement’s productive and internal tasks — our own investment around it.
people.totalSeconds* integer
people.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
revenue* object
revenue.amountMinor* integer 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 Items counted in the amount.
revenue.otherCurrencyItemCount* integer Items in another currency: counted here, never summed into the amount.
rates* object 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
rates.perFixedPriceHours* FixedPriceControllingRate
rounding* "half-up-minor-unit-per-hour" How the rates are rounded: once, half up, to a whole minor unit per hour. Seconds and amounts are never rounded.
scope* "organization" Whose work the figures cover: everybody who booked on the agreement, not one person.

FixedPriceControllingRate

amountMinor* integer The revenue the rate divides, in minor units of currency.
seconds* integer The hours the rate divides by, in seconds — exactly the ones it names.
rateMinorPerHour* integer | null amountMinor per hour, rounded once to a whole minor unit, half up. null without hours — no hours is not a rate of zero.

FixedPriceItem

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
agreementId* string (uuid) | null The agreement the item belongs to. null only for a standalone expense. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) The project the item is billed under. Its customer is the agreement’s. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
kind* "service" | "expense" 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 Becomes the invoice line’s description, together with the service days. Workshop-Tag
quantityHundredths* integer The quantity in hundredths of unit: 200 is two days. Independent of how many days the item has. 200
unit* string Tag
unitPriceMinor* integer The price per unit in minor units, entered per item. 180000
amountMinor* integer round(quantity × unit price) in minor units, rounded once when the item was written and stored. 360000
currency* string The organization’s currency when the item was written. EUR
easybillPositionId* string | null 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. 236898474
days* string[] 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[] Who shares a service item’s revenue. Empty for an expense.
people.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
people.weight* integer The share of the item’s revenue, relative to the other people. Equal by default. 1
policy* FixedPriceItemPolicy
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

FixedPriceItemBlocker

object | object

FixedPriceItemPolicy

state* "free" | "closed" | "billed" 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" 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" Like update, and locked for everybody while any invoice claim names the item, a withdrawn one included — delete that invoice first.
blockers* FixedPriceItemBlocker[] Why the state is closed; empty otherwise.

FixedPriceRevision

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
subject* "agreement" | "item"
subjectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
agreementId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
action* "created" | "updated" | "deleted"
actorPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
recordedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
snapshot* object The subject as it stood before this write; for created, its initial values.
reason* string | null

GoogleCalendarAuthorizeUrl

url* string (uri) https://accounts.google.com/o/oauth2/v2/auth?…

GoogleCalendarConnection

configured* boolean
connection* object | null
connection.googleAccountEmail* string (email) anna@suora.com
connection.calendarIds* string[] ["anna@suora.com"]
connection.includeDetails* boolean
connection.connectedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
connection.lastSyncedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
connection.authorizationFailedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

GoogleCalendarList

data* object[]
data.id* string anna@suora.com
data.name* string Anna Wolf
data.primary* boolean
data.selected* boolean

GoogleCalendarSync

reported* integer 3
status* "reported" | "not-configured" | "not-connected" | "no-calendars" | "authorization-failed" | "outside-retention" | "future-window" | "throttled" | "failed" 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

outcome* "joined" | "already-member" | "no-google-account" | "no-hosted-domain" | "no-matching-domain" | "domain-not-currently-verified" | "membership-conflict"
organizationId* string | null

HarvestAdoptRequest

timeEntryIds* string (uuid)[] 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) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

HarvestAdoptResult

results* object[]
results.timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
results.status* "adopted" | "unchanged" | "failed"
results.harvestTimeEntryId? string
results.adopted? ("date" | "start" | "end" | "notes" | "task")[]
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"
results.detail? string
adopted* integer
unchanged* integer
failed* integer

HarvestCompareFieldDifferenceDetail

field* "date" | "start" | "end" | "notes" | "project" | "task"
expected* string
remote* string

HarvestCompareRequest

timeEntryIds* string (uuid)[] 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) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

HarvestCompareResult

results* object[]
results.timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
results.status* "matches" | "diverged" | "harvest-deleted" | "failed"
results.harvestTimeEntryId? string
results.differences? ("date" | "start" | "end" | "notes" | "project" | "task")[]
results.differenceDetails? HarvestCompareFieldDifferenceDetail[]
results.detail? string
results.localUpdatedAt? string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
results.remoteUpdatedAt? string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

HarvestConnection

configured* boolean
connection* object | null
connection.accountId* string 1234567
connection.harvestUserEmail* string (email) anna@suora.com
connection.harvestUserName* string Anna Wolf
connection.harvestUserTimezone* string Berlin
connection.timestampMode* boolean
connection.connectedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
connection.lastVerifiedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
connection.authorizationFailedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

HarvestExportRequest

timeEntryIds* string (uuid)[]

HarvestExportResult

results* object[]
results.timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
results.status* "created" | "updated" | "failed"
results.harvestTimeEntryId? string
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"
results.detail? string
created* integer
updated* integer
failed* integer

HarvestImportRequest

from* string 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. 2026-08-14
to* string Inclusive, and never before from. 2026-08-14
preview* boolean When true, nothing is written and every result says what would happen.
harvestTimeEntryIds? string[] 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. ["987654321"]
personId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

HarvestImportResult

preview* boolean
results* object[]
results.harvestTimeEntryId* string 987654321
results.status* "imported" | "skipped"
results.timeEntryId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
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"
results.detail? string
results.archived? "task" | "project" | "customer" 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. project
results.plannedSeconds* integer | null 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. 5436
results.plannedStartedAt* string (date-time) | null 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 The instant plannedSeconds is placed to end at. null alongside it.
results.harvest* object
imported* integer
skipped* integer
claimsWritten* integer 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. 3
truncated* boolean true when the range holds more than 2000 entries; the rest were not examined.

HarvestPeriodSummary

totalSeconds* integer 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. 329040
billableSeconds* integer 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. 288000
amounts* object[] 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 EUR
amounts.billableAmountMinor* integer 300000
rowsWithoutAmount* integer Report rows that state billable hours but no amount — counted and named rather than summed as zero (invariant 19).

HarvestProjectList

data* object[]
data.id* string 20
data.name* string Website

HarvestProjectMapping

harvestProjectId* string 20

HarvestProjectTeamImportRequest

from* string 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. 2026-08-14
to* string Inclusive, and never before from. 2026-08-14
preview* boolean When true, nothing is written and every result says what would happen.

HarvestProjectTeamImportResult

preview* boolean
members* object[]
members.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
members.harvestMatch* "matched" | "unmatched" | "ambiguous" | "denied" 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
members.skipped* integer
members.claimsWritten* integer
members.truncated* boolean
members.results* object[]
imported* integer
skipped* integer
claimsWritten* integer
truncated* boolean true when any one member's own range was truncated.
otherTrackers* object[] 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
otherTrackers.harvestUserName* string | null
otherTrackers.harvestUserEmail* string | null 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" 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) Present only for unassigned-member — the Timjami person to assign. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
otherTrackersTruncated* boolean true when the project-wide read behind otherTrackers held more than the cap.

HarvestSecondsRequest

from* string A calendar day, YYYY-MM-DD. 2026-08-14
to* string A calendar day, YYYY-MM-DD. 2026-08-14
preview? boolean
personId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17

HarvestSecondsResult

preview* boolean
results* object[]
results.timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
results.harvestTimeEntryId* string 987654321
results.status* "corrected" | "unchanged" | "failed"
results.reason? "no-placement" | "foreign-account-link" | "foreign-user" | "running" | "not-minute-precise" | "thymian-origin" | "billed"
results.detail? string
results.fromSeconds? integer
results.toSeconds? integer
corrected* integer
unchanged* integer
failed* integer
gainedSeconds* integer 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 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 How much those seams overlap in total, seconds.
harvestOverlaps* integer 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 true when Harvest holds more than 2000 entries in the range; the rest were not examined.

HarvestTaskList

data* object[]
data.id* string 30
data.name* string Design

HarvestTaskMapping

harvestProjectId* string 20
harvestTaskId* string 30

Hint

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userId* string
source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
signal* "presence" | "absence"
externalId* string | null
clientHintId* string | null
path* HintPath
details* string | null
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
durationSeconds* integer | null 5700
occurrenceCount* integer 12
version* integer 1
timeEntryId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
dismissedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
isResolved* boolean
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

HintClassification

kind* "presence" | "task" | "ignore" | "customer" | "project" | "absence" 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. task
taskId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

HintEvaluation

suggestions* object[]
suggestions.hintIds* string (uuid)[] Which of the requested hints this suggestion covers.
suggestions.taskId* string (uuid) | null The suggested task, or null where nothing matched confidently.
suggestions.summary* string A short, human-readable description of the covered hints.
suggestions.confidence* "low" | "medium" | "high" The model’s own self-assessment — not validated against anything.

HintPath

HintPathNode[]

HintPathNode

id* string Stable id from the source system — never renamed, unlike the label. thymian
label* string Timjami

HintRule

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
path* HintPath
kind* "presence" | "task" | "ignore" | "customer" | "project" | "absence" 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. task
taskId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

HintRuleInput

source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
subject* string
path? HintPath
kind* "presence" | "task" | "ignore" | "customer" | "project" | "absence" 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. task
taskId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

HintSubject

source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
subject* string Ruhrstraße 70
path* HintPath
hintCount* integer 23
occurrenceCount* integer 41
observedSeconds* integer 52200
firstSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

HintTaskSuggestion

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
origin* "rule" | "history" history
lastBookedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
acceptedCount* integer | null 3

HintWithSuggestion

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userId* string
source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
signal* "presence" | "absence"
externalId* string | null
clientHintId* string | null
path* HintPath
details* string | null
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
durationSeconds* integer | null 5700
occurrenceCount* integer 12
version* integer 1
timeEntryId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
dismissedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
isResolved* boolean
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
taskSuggestion* HintTaskSuggestion
classifications* HintClassification[]

HistoricalPeriodPreview

periodStartMonth* string A calendar month, YYYY-MM. 2026-08
outstandingSeconds* integer Open seconds a history mode would take on, allowance months included.
fixedPriceItemCount* integer Completed fixed-price items it would take on. They carry no seconds, so a period can hold these alone.

HoursOverTime

bucket* "week" | "month" | "quarter" The grain, chosen from the range: weeks up to 92 days, months up to 24 months, quarters beyond.
series* HoursOverTimeSeries[] 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[] Every bucket of the range, an empty one as zero. Empty when from is omitted and the subject has no hours.

HoursOverTimeBucket

firstDay* string A calendar day, YYYY-MM-DD. 2026-08-14
lastDay* string A calendar day, YYYY-MM-DD. 2026-08-14
totalSeconds* integer
seconds* object Seconds per series key; a key without seconds in this bucket is absent. Every key is one of series.

HoursOverTimeSeries

key* string 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 The person’s or project’s name; null for settlement statuses and for self/others, which the client words.
color* string | null The project’s own colour; null everywhere else.

ImpersonationSession

sessionToken* string
sessionId* string
expiresAt* string (date-time)
user* object
user.id* string
user.name* string
user.email* string (email)
user.image* string | null

InsertBreak

startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
affected* string (uuid)[] 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 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

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
notes? string | null
affected* object[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
affected.keep* boolean
editReason? string 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

code* string An invite code, in any casing or spacing — normalized server-side. TIMJ-AAAA-AAAA

InviteCodeCheckResult

email* string | null The address this code is bound to, or null when it redeems for anybody.

InviteCodeRedemption

id* string (uuid)
userId* string | null Null once the account was deleted; the redemption still counts as a use of the code.
userEmail* string (email) | null
userName* string | null
redeemedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
organizationId* string | null
organizationName* string | null

Invoice

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
status* "draft" | "transferred" | "withdrawn" '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. draft
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
grouping* "member" | "project" | "task" | "entry" 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. project
periodStart* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
periodEnd* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
currency* string ISO-4217 currency code. EUR
subtotalMinor* integer 372500
notes* string | null
transferredAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
withdrawnAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
export* InvoiceExport
transferredByHand* boolean
document* InvoiceDocument
recordedDocument* InvoiceRecordedDocument
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAtPrecise* string

InvoiceDocument

provider* InvoicingProvider & unknown
state* "deleted" | "cancelled" | "draft" | "paid" | "partiallyPaid" | "overdue" | "sent" | "finalized" | "open" | "unknown" 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. sent
number* string | null The Rechnungsnummer the tool assigned on finalising — null while a draft. RE-2026-0042
archived* boolean | null Easybill only, null otherwise: archived there. Not a state: an archived invoice is still paid or not.
amountMinor* integer | null 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. 443275
amountNetMinor* integer | null 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. 372500
paidAmountMinor* integer | null What the tool records as paid, in minor units. 0
paidDay* string | null When the tool records it as paid. 2026-08-14
documentDay* string | null The Rechnungsdatum. 2026-08-14
dueDay* string | null 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. 2026-08-14
duePassed* boolean 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 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. 2026-08-14
sentAt* string (date-time) | null sevdesk only, null otherwise: when the document was sent, as sevdesk records it. 2026-07-29T09:00:00.000Z
fetchedAt* string (date-time) When Timjami last read this from the tool — how current everything above is. 2026-07-29T09:00:00.000Z

InvoiceDraft

currency* string ISO-4217 currency code. EUR
lines* InvoiceDraftLine[]
subtotalMinor* integer
entryCount* integer
durationSeconds* integer
allowanceSeconds* integer 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 The fixed-price items that made it onto a line. Items are no entries.
fixedPriceAmountMinor* integer Their total in minor units, a part of subtotalMinor.
excluded* InvoiceExclusion[]
projectIds* string (uuid)[]
periodStart* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
periodEnd* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

InvoiceDraftLine

position* integer 1
kind* "time" | "fixed_price" 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. time
description* string Website Relaunch — Frontend
quantityHundredths* integer 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. 150
unit* string 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. h
quantitySeconds* integer | null The exact seconds a time line’s claims are measured against. null for a fixed_price line. 5400
quantityHours* string | null Decimal hours of a time line; null for a fixed_price line. 1.50
fixedPriceItemId* string (uuid) | null The fixed-price item a fixed_price line bills; null for a time line. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
unitRateMinor* integer 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. 8500
amountMinor* integer 12750
currency* string ISO-4217 currency code. EUR
projectId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
allowanceSeconds* integer 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. 0
entryCount* integer 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

reason* "not-billable" | "fixed-price" | "missing-rate" | "already-invoiced" | "outside-selection" | "outside-period" | "other-currency" 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. missing-rate
entryCount* integer 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
allowanceSeconds* integer 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 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 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[] 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

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskName* string
entryCount* integer
durationSeconds* integer
allowanceSeconds* integer

InvoiceExport

provider* InvoicingProvider
documentId* string | null
exportedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
unclear* boolean
documentLink* object | null
documentLink.url* string (uri)
documentLink.providerName* string sevdesk

InvoiceLine

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
position* integer 1
kind* "time" | "fixed_price" 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. time
description* string Website Relaunch — Frontend
quantityHundredths* integer 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. 150
unit* string 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. h
quantitySeconds* integer | null The exact seconds a time line’s claims are measured against. null for a fixed_price line. 5400
quantityHours* string | null Decimal hours of a time line; null for a fixed_price line. 1.50
fixedPriceItemId* string (uuid) | null The fixed-price item a fixed_price line bills; null for a time line. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
unitRateMinor* integer 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. 8500
amountMinor* integer 12750
currency* string ISO-4217 currency code. EUR
projectId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
entryCount* integer 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. 3
allowanceSeconds* integer 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. 0

InvoiceList

data* InvoiceListItem[]
nextCursor* string | null Cursor for the next page, or null when the list is exhausted.

InvoiceListItem

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
status* "draft" | "transferred" | "withdrawn" '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. draft
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
grouping* "member" | "project" | "task" | "entry" 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. project
periodStart* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
periodEnd* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
currency* string ISO-4217 currency code. EUR
subtotalMinor* integer 372500
notes* string | null
transferredAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
withdrawnAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
export* InvoiceExport
transferredByHand* boolean
document* InvoiceDocument
recordedDocument* InvoiceRecordedDocument
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAtPrecise* string
customerIcon* object | null
customerIcon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
customerIcon.contentType* "image/webp" | "image/png"
customerIcon.width* integer 256
customerIcon.height* integer 256
customerIcon.byteSize* integer 8452
customerIcon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerIcon.checksum* string a3c9…

InvoiceRecordedDocument

state* "issued" | "sent" | "overdue" | "paid" 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. sent
number* string | null The Rechnungsnummer as the invoicing program assigned it, copied by hand. Neither unique nor checked — Timjami assigns no number (ADR 0068). RE-2026-0042
documentDay* string | null The Rechnungsdatum. 2026-08-14
dueDay* string | null The Fälligkeitsdatum. 2026-08-14
duePassed* boolean 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 A link to the document, always https://. https://example.test/rechnungen/42
sentDay* string | null When it was sent to the customer. 2026-08-14
paidDay* string | null When it was paid. 2026-08-14

InvoiceStatusEntry

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
status* "draft" | "transferred" | "withdrawn" '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. draft
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
periodStart* string (date-time) The start of the billed period. 2026-07-29T09:00:00.000Z
periodEnd* string (date-time) The end of the billed period, exclusive. 2026-07-29T09:00:00.000Z
transferredAt* string (date-time) | null When this left Timjami for the invoicing tool. null for a draft. 2026-07-29T09:00:00.000Z
withdrawnAt* string (date-time) | null When a transferred invoice was taken back into Timjami. null otherwise. 2026-07-29T09:00:00.000Z
export* InvoiceStatusExport
transferredByHand* boolean Transferred while no invoicing tool was connected — the transfer is the whole settlement.
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

InvoiceStatusExport

provider* InvoicingProvider
exportedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

InvoiceStatusList

data* InvoiceStatusEntry[]
nextCursor* string | null Cursor for the next page, or null when the list is exhausted.

InvoiceWithLines

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
status* "draft" | "transferred" | "withdrawn" '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. draft
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
grouping* "member" | "project" | "task" | "entry" 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. project
periodStart* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
periodEnd* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
currency* string ISO-4217 currency code. EUR
subtotalMinor* integer 372500
notes* string | null
transferredAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
withdrawnAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
export* InvoiceExport
transferredByHand* boolean
document* InvoiceDocument
recordedDocument* InvoiceRecordedDocument
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAtPrecise* string
servicePeriodStart* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
servicePeriodEnd* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lines* InvoiceLine[]
projects* object[]
projects.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.name* string
budgets* object[]
budgets.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.name* string
tasks* object[]
tasks.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
tasks.name* string
tasks.projectName* string

InvoicingProvider

"easybill" | "sevdesk"

InvoicingProviderConnection

provider* InvoicingProvider | unknown | null The invoicing tool this organization has connected, or null when it bills by hand. At most one.

IosNotificationPushToken

installationId* string (uuid)
environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

IosNotificationPushTokenForget

installationId* string (uuid)

IosNotificationPushTokenRegistration

installationId* string (uuid)
environment* "sandbox" | "production"
token* string

JoinableProjectList

data* object[]
data.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
data.projectName* string
data.earliestMonth* string A calendar month, YYYY-MM. 2026-08
data.latestMonth* string | null A calendar month, YYYY-MM. 2026-08

LinkHint

timeEntryId* string (uuid) | null The time entry this hint became, or null to unlink. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

LiveActivityStartToken

environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

LiveActivityStartTokenRegistration

environment* "sandbox" | "production"
token* string

LiveActivityUpdateToken

activityId* string
environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

LiveActivityUpdateTokenRegistration

activityId* string
environment* "sandbox" | "production"
token* string

McpConsentDecision

redirectURI* string (uri) Where the browser must be sent to hand the result back to the client.

McpConsentRequest

clientName* string | null
redirectHost* string | null
scopes* string[]
grants* object
grants.hintsReadEnabled* boolean
grants.budgetWriteEnabled* boolean
grants.moneyReadEnabled* boolean
grants.billingWriteEnabled* boolean
unavailable* string[]

McpGrant

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
clientId* string The OAuth client id. A lookup key chosen by the client, never a label.
clientName* string | null The name chosen by the dynamically registered client. Advisory only.
redirectHost* string | null Host of the redirect URI verified during the most recent approval.
organization* object
organization.id* string
organization.name* string
budgetWriteEnabled* boolean Live second gate for ordinary budget writes. It never adds a scope to an access token.
canEnableBudgetWrite* boolean Whether current membership and role allow the owner to turn budget writing on now.
moneyReadEnabled* boolean 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 Whether the owner is still a member of the grant’s organization and may turn money reading on now.
billingWriteEnabled* boolean 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 Whether the owner is still a member of the grant’s organization and may turn billing writes on now.
hintsReadEnabled* boolean 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 Whether the owner is still a member of the grant’s organization and may turn hint reading on now.
scopes* string[] 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) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

Me

user* object
user.id* string
user.name* string
user.email* string
user.avatarUrl* string | null
user.imagedeprecated* string | null
user.avatar* ResourceIcon
person* object | null
person.id* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
person.name* string The organization’s name for this person — the account’s while it has one, the row’s own otherwise.
activeOrganizationId* string | null
preferences* UserPreferences
organizationSettings* OrganizationSettings
organizationFeatures* Feature[] 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" How rates are presented in the UI. Does not affect stored data. hourly
effectiveShowRunningTimerAvatars* boolean
tourAvailable* boolean
effectiveTourEnabled* boolean
jamiAvailable* boolean
effectiveJamiEnabled* boolean
effectiveJamiNotesEnabled* boolean 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[]
isPlatformOperator* boolean
canCreateOrganization* boolean Whether this account may create an organization: a platform operator, or holding an unused invite-code redemption.
isImpersonating* boolean

MemberAvatars

avatars* object[]
avatars.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
avatars.avatarUrl* string
avatars.avatardeprecated* object | null Deprecated uploaded-avatar metadata. Use avatarUrl, which also falls back to the identity-provider image.

MemberDailyReport

dailyOrgReportEmailEnabled* boolean Whether a current holder of report.readOthers receives the daily organization revenue digest. false
dailyOrgReportSendWeekdays* integer[] ISO weekdays (1 = Monday … 7 = Sunday) the digest is sent on. The covered period is derived from this schedule, not stored separately. [1,2,3,4,5]
dailyOrgReportSendHour* integer 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. 7

MemberGoogleAccounts

userIds* string[]

MergeTimeEntries

entryIds* string (uuid)[]
notes? string | null

MoveBillingAllocation

toProposalId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
reason* string | null

MoveCustomerPeriodClose

closedThroughDay* string | null 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. 2026-08-14
reason? string 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)[] 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

closedThroughDay* string | null 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. 2026-08-14
reason? string 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

"harvest" | "company-metrics" | "agent-usage" | "day-export" | "tour" | "jami" | "jami-notes" | "max-view" | "entry-split"

OrganizationDashboard

asOf* string (date-time) The single instant running timers were measured at. 2026-07-29T09:00:00.000Z
day* string A calendar day, YYYY-MM-DD. 2026-08-14
weekFromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
monthFromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
currency* string ISO-4217 currency code. EUR
revenue* object
revenue.today* object
revenue.week* object
revenue.month* object
attendanceToday* object 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
attendanceToday.billableSeconds* integer
customers* object[]
customers.customerId* string (uuid)
customers.customerName* string
customers.revenue* object
people* object[]
people.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
people.name* string
people.avatarUrl* string | null The final profile picture URL: the person's Timjami avatar, otherwise the identity provider's picture.
people.imagedeprecated* string | null Deprecated compatibility alias for avatarUrl.
people.scheduledSeconds* integer The work schedule demand before absences are applied.
people.absentSeconds* integer
people.expectedSeconds* integer Today's target after absences are applied.
people.trackedSeconds* integer Tracked seconds including a running timer measured at asOf.
people.billableSeconds* integer Of trackedSeconds, the seconds on billable tasks — task billability, not whether a rate resolved.
people.hasAbsence* boolean
people.isRunning* boolean
people.tasks* object[]
notStartedPersonIds* string (uuid)[] Scheduled members with zero tracked seconds and no absence covering the day.

OrganizationDomain

domain* string
verificationHost* string
verificationValue* string
verifiedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastCheckedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
verificationLostAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
autoJoinEnabled* boolean

OrganizationFeature

"planning" | "customer-portal" | "tickets" | "absences"

OrganizationFeatureSwitches

planning* boolean
customer-portal* boolean
tickets* boolean
absences* boolean

OrganizationIcon

icon* ResourceIcon

OrganizationLoginPolicy

enabled* boolean
enabledAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
enabledByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
lastDisabledAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastDisabledReason* "owner" | "domain-verification-lost" | null | null
domainVerified* boolean
viewerHasGoogleAccount* boolean

OrganizationMembers

data* object[]
data.memberId* string | null 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) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
data.userId* string | null 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
data.role* string | null May be comma-joined (e.g. "admin,member") — never compare with equality. null when memberId is.
data.name* string
data.email* string
data.joinedAt* string (date-time) | null When the membership began. null when memberId is.

OrganizationRunningTimers

entries* object[]
entries.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
entries.task* object | null
entries.project* object | null
entries.customer* object

OrganizationSettings

organizationId* string
currency* string ISO-4217 currency code. EUR
rateDisplay* "hourly" | "daily" How rates are presented in the UI. Does not affect stored data. hourly
hoursPerDay* string 8.00
timeZone* string Europe/Berlin
weekStartsOn* integer 1
hintRetentionDays* integer Days a hint is kept before POST /hints prunes it. Enforced when a collector reports, not by a scheduler. 30
runningTimerAvatarsEnabled* boolean Whether the header shows avatars of colleagues whose timer is running. The organization-wide default; a member may still turn it off for themselves. true
tourEnabled* boolean 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. true
jamiEnabled* boolean 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. true
trackingOpenMonths* integer | null 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. 2
earliestOpenDay* string | null 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. 2026-06-01
llmHintEvaluationEnabled* boolean Whether administrators permit manual LLM evaluation of hints. false
billingEscalationDelayDays* integer Written by PUT /organization/billing-escalation, never here. 7
billingEscalationRecipientPersonId* string (uuid) | null Written by PUT /organization/billing-escalation, never here. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

OtlpExportResponse

unknown

OtlpLogsRequest

resourceLogs? object[] | null
resourceLogs.resource? object | null
resourceLogs.scopeLogs? object[] | null

PeriodCloseChange

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
fromDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
toDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
reason* string | null
movedByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
movedByName* string | null
movedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PeriodCloseResult

projects* object[]
projects.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.moved* boolean
projects.fromDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
projects.closedThroughDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14

Person

id* string (uuid) The person’s stable id — the same value every personId field of this API carries, time entries included. Never a login account id. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
displayName* string The linked account’s name while there is one, otherwise the name Timjami keeps for the person.
email* string | null The linked account’s address, otherwise an address recorded when the person was imported (never verified), otherwise null.
status* "active" | "inactive" 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

personId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
from* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
until* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
totalSeconds* integer
billableSeconds* integer
entryCount* integer
daysWorked* integer
perDay* PersonActivityDay[]
projects* PersonActivityProject[]

PersonActivityDay

day* string A calendar day, YYYY-MM-DD. 2026-08-14
totalSeconds* integer
billableSeconds* integer
projects* PersonActivityDayProject[]
tasks* PersonActivityDayTask[]

PersonActivityDayProject

projectId* string (uuid)
totalSeconds* integer
billableSeconds* integer

PersonActivityDayTask

taskId* string (uuid)
projectId* string (uuid)
totalSeconds* integer
billableSeconds* integer

PersonActivityNote

text* string
entryCount* integer
totalSeconds* integer
firstDay* string A calendar day, YYYY-MM-DD. 2026-08-14
lastDay* string A calendar day, YYYY-MM-DD. 2026-08-14

PersonActivityProject

customerId* string (uuid)
customerName* string
projectId* string (uuid)
projectName* string
totalSeconds* integer
billableSeconds* integer
tasks* PersonActivityTask[]

PersonActivityTask

taskId* string (uuid)
taskName* string
isBillable* boolean
totalSeconds* integer
noteCount* integer
notesTruncated* boolean
notes* PersonActivityNote[]

PersonioAttendanceExport

active* boolean Whether the export writes attendances: its sync target exists and is enabled.

PersonioPersonMappings

persons* object[]
persons.id* string (uuid)
persons.name* string
persons.email* string | null
persons.status* "assigned" | "open" | "ambiguous"
persons.assignment* object | null
persons.suggestions* object[]
personioPersons* object[]
personioPersons.id* string (uuid)
personioPersons.name* string
personioPersons.email* string | null
personioPersons.assignedTo* string | null
openCount* integer Persons read by the sync service without a confirmed, live counterpart. Includes ambiguous suggestions; these persons cannot resolve in the export.

PersonRevenue

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
from* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
until* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
currency* string ISO-4217 currency code. EUR
amountMinor* integer 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 Billable seconds no rate resolved for; reported rather than valued at zero.
runningIncluded* boolean True when includeRunning was asked for and the subject's own running entry falls inside this window.

PlannedPreviewPeriod

periodStartMonth* string A calendar month, YYYY-MM. 2026-08
plannedSeconds* integer 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 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
weekStartDay* string 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. 2026-09-14
plannedSeconds* integer 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. 72000
note* string | null
expectedRemainingSeconds* integer | null 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 When expectedRemainingSeconds was last set; null without a correction. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Who first set this cell. Null once that person was deleted — the row outlives them. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
updatedByPersonId* string (uuid) | null Who last wrote this cell — the same as createdByPersonId until a second write changes it. Null once that person was deleted. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PlanningContext

targetType* "customer" | "person" | "project" | "budget"
targetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
targetName* string
notes* string
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

PlanningContextList

contexts* PlanningContext[]

PlanningForecast

currentWeekStartDay* string 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. 2026-09-14
fromMonth* string A calendar month, YYYY-MM. 2026-08
untilMonth* string A calendar month, YYYY-MM. 2026-08
weeks* object[]
weeks.weekStartDay* string 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. 2026-09-14
weeks.month* string A calendar month, YYYY-MM. 2026-08
customers* object[]
customers.color* string #228be6
customers.icon* ResourceIcon
customers.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customers.name* string
customers.archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
projects* object[]
projects.icon* ResourceIcon
projects.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.name* string
projects.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.customerName* string
projects.color* string | null #228be6
projects.archivedAt* string (date-time) | null 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. 2026-07-29T09:00:00.000Z
budgets* object[]
budgets.budgetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.name* string
budgets.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.kind* "monthly" | "total"
budgets.projectIds* string (uuid)[]
budgets.validFromMonth* string A calendar month, YYYY-MM. 2026-08
budgets.validUntilMonth* string | null A calendar month, YYYY-MM. 2026-08
budgets.months* object[]
budgets.archivedAt? string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
budgets.customerName* string
budgets.total* object
allocations* PlanningOverviewAllocation & object[]
issues* object[]
issues.kind* "over-budget" | "ending-agreement" | "uncovered-work"
issues.budgetId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
issues.allocationId* string (uuid) | null For uncovered-work, its one allocation; otherwise null. Redundant with allocationIds, kept for existing clients (TIM-234). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
issues.allocationIds* string (uuid)[] 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 A calendar month, YYYY-MM. 2026-08
issues.seconds* integer

PlanningOverview

currentWeekStartDay* string 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. 2026-09-14
weeks* object[] 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 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. 2026-09-14
weeks.month* string A calendar month, YYYY-MM. 2026-08
people* object[]
people.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
people.weeks* object[]
allocations* PlanningOverviewAllocation[] Every stored allocation whose week falls in the requested range, any person or project.
tracked* object[]
tracked.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
tracked.projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
tracked.weekStartDay* string 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. 2026-09-14
tracked.trackedSeconds* integer 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[]
customers.color* string #228be6
customers.icon* ResourceIcon
customers.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customers.name* string
customers.archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
budgetWeeks* object[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetWeeks.weekStartDay* string 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. 2026-09-14
budgetWeeks.plannedSeconds* integer
customerWeeks* object[]
customerWeeks.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerWeeks.weekStartDay* string 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. 2026-09-14
customerWeeks.plannedSeconds* integer
customerWeeks.trackedSeconds* integer
customerWeeks.unassignedSeconds* integer Planned seconds for this customer with no unique budget assignment.
trackedCustomers* object[]
trackedCustomers.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
trackedCustomers.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
trackedCustomers.weekStartDay* string 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. 2026-09-14
trackedCustomers.trackedSeconds* integer
projects* object[] 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
projects.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.name* string
projects.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.customerName* string
projects.color* string | null #228be6
projects.archivedAt* string (date-time) | null 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. 2026-07-29T09:00:00.000Z
budgets* object[]
budgets.budgetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.name* string
budgets.customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgets.kind* "monthly" | "total"
budgets.projectIds* string (uuid)[]
budgets.validFromMonth* string A calendar month, YYYY-MM. 2026-08
budgets.validUntilMonth* string | null A calendar month, YYYY-MM. 2026-08
budgets.months* object[]
budgets.archivedAt? string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PlanningOverviewAllocation

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
weekStartDay* string 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. 2026-09-14
plannedSeconds* integer 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. 72000
note* string | null
expectedRemainingSeconds* integer | null 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 When expectedRemainingSeconds was last set; null without a correction. 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Who first set this cell. Null once that person was deleted — the row outlives them. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
updatedByPersonId* string (uuid) | null Who last wrote this cell — the same as createdByPersonId until a second write changes it. Null once that person was deleted. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
forecastBudgetId* string (uuid) | null Uniquely assigned forecast budget for this week, or null. Never changes the stored planning target or actual consumption. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
matchedActualSeconds* integer Finished billable seconds this person tracked in this (current) week that are credited to this plan, each second at most once.
remainingPlannedSeconds* integer 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
createdByName* string | null
updatedByName* string | null

PlanningReadersAwaitingAbsences

persons* object[]
persons.id* string (uuid)
persons.name* string

PlatformAccessLog

PlatformAccessLogEntry

id* string (uuid)
actorUserId* string
actorName* string
actorEmail* string
action* string
organizationId* string | null
organizationName* string | null
recordedAt* string (date-time)

PlatformAppInstallation

installationId* string (uuid)
app* string
version* string
platform* string
architecture* string
firstSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PlatformAppVersions

activeWindowDays* integer
versions* object[]
versions.app* string
versions.version* string
versions.outdated* boolean
versions.recentInstallations* integer
versions.knownInstallations* integer
versions.lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
versions.users* object[]

PlatformIosNotificationPushToken

organizationId* string
installationId* string (uuid)
environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PlatformLiveActivityStartToken

organizationId* string
environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PlatformLiveActivityUpdateToken

organizationId* string
activityId* string
environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PlatformOrganization

id* string
name* string
slug* string
icon* ResourceIcon
createdAt* string (date-time)
memberCount* integer
lastActivityAt* string (date-time) | null
trackedSecondsLast7Days* integer
trackedSecondsPrevious7Days* integer
activePeopleLast7Days* integer
excludedFromAnalytics* boolean

PlatformOrganizationDetail

id* string
name* string
slug* string
icon* ResourceIcon
createdAt* string (date-time)
memberCount* integer
lastActivityAt* string (date-time) | null
trackedSecondsLast7Days* integer
trackedSecondsPrevious7Days* integer
activePeopleLast7Days* integer
excludedFromAnalytics* boolean
members* PlatformOrganizationMember[]
features* OperatorFeature[]
switchedOff* OrganizationFeature[]

PlatformOrganizationMember

status* "active" | "invited"
userId* string | null
name* string | null
email* string
role* string
lastLoginAt* string (date-time) | null
invitedAt* string (date-time) | null
invitationExpired* boolean
appInstallations* PlatformAppInstallation[]
features* OperatorFeature[]

PlatformOrganizations

organizations* PlatformOrganization[]

PlatformProductRelease

version* string | null
tag* string | null
releaseUrl* string (uri) | null
releaseWorkflow* object | null
releaseWorkflow.status* string
releaseWorkflow.conclusion* string | null
releaseWorkflow.htmlUrl* string (uri)
releaseWorkflow.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
releaseWorkflow.headSha* string
releaseWorkflow.headCommitMessage* string | null
activeReleaseWorkflows* PlatformWorkflowRun[]

PlatformPushLogEntry

timestamp* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
level* "debug" | "info" | "warn" | "error" | "fatal"
event* string
message* string
outcome* string | null
pushType* string | null
tokenKind* string | null
platform* string | null
kind* string | null
activityId* string | null
reason* string | null
detail* string | null

PlatformPushLogs

entries* PlatformPushLogEntry[]
truncated* boolean

PlatformPushTokens

iosNotificationTokens* PlatformIosNotificationPushToken[]
widgetTokens* PlatformWidgetPushToken[]
liveActivityStartToken* PlatformLiveActivityStartToken
liveActivityUpdateTokens* PlatformLiveActivityUpdateToken[]

PlatformReleases

web* object
web.deployWorkflow* object | null
web.activeDeployWorkflows* PlatformWorkflowRun[]
web.pendingCiWorkflows* PlatformWorkflowRun[]
web.blockingCiWorkflow* object | null
cli* PlatformProductRelease
desktop* PlatformProductRelease
extension* PlatformProductRelease
safariExtension* PlatformProductRelease
ios* PlatformProductRelease
raycast* PlatformProductRelease

PlatformUsage

windowDays* integer
areas* object[]
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"
areas.kind* "activity" | "setup" | "held"
areas.organizationsUsing* integer
areas.organizationsEligible* integer
organizations* object[]
organizations.id* string
organizations.name* string
organizations.slug* string
organizations.icon* ResourceIcon
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")[]
organizations.usage* object[]

PlatformWidgetPushToken

organizationId* string
platform* "ios" | "watchos"
kind* string
environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

PlatformWorkflowRun

status* string
conclusion* string | null
htmlUrl* string (uri)
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
headSha* string
headCommitMessage* string | null

PolicyBlocker

object | object | object

PreviewAllowanceWriteOff

months* AllowanceWriteOffMonthRequest[] 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

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string (uuid)[]
budgetIds? string (uuid)[] 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)[] 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 Inclusive lower bound on the entry start, or null for no lower bound. 2026-07-29T09:00:00.000Z
until* string (date-time) Exclusive upper bound on the entry start. 2026-07-29T09:00:00.000Z
grouping* "member" | "project" | "task" | "entry" 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. project

PreviewProjectWriteOff

from? string Inclusive. Omitted means no lower bound — a project row has an open start, exactly like „Abzurechnen" itself. 2026-08-14
until? string Inclusive. Omitted means through today — set this to the last day of last month to reproduce a row shown with „Laufenden Monat ausblenden" on. 2026-08-14

PreviewWriteOff

entryIds* string (uuid)[] 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

kind* "catch-up" | "period" catch-up: the one proposal collect-history makes. period: one of the proposals separate-periods makes.
periodStartMonth* string The first period with something open. 2026-08
lastMonth* string The range’s last month, inclusive. 2026-08
seconds* integer Seconds the proposal would hold, priced or not — everybody’s, organization-wide. 0 when it holds only fixed-price items.
amountMinor* integer | null 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 Billable seconds in the range no rate prices. null with amountMinor.
otherCurrencyItemCount* integer | null Completed fixed-price items in another currency, which the proposal holds and amountMinor leaves out. null with amountMinor.

PricedPreviewPeriod

periodStartMonth* string A calendar month, YYYY-MM. 2026-08
lastMonth* string The period’s last month, inclusive. 2026-08
dueDay* string (date)
state* "ready" | "running" | "upcoming" 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 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 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 Billable seconds in the period that no rate prices. null when upcoming.
otherCurrencyItemCount* integer | null Completed fixed-price items in another currency, which the proposal holds and amountMinor leaves out. null when upcoming.
proposes* boolean 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string Übertrag alte Konditionen
validFromMonth* string A calendar month, YYYY-MM. 2026-08
validUntilMonth* string | null A calendar month, YYYY-MM. 2026-08
entersDirectly* boolean 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

type* string https://timjami.suora.cloud/problems/not-found
title* string Not found
status* integer 404
detail? string
errors? object[] Field-level errors, present on validation problems.
errors.field* string
errors.message* string
requestId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

Project

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string Website Relaunch
aliases* string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
rules* RateRule[] The project’s billing rules, least specific first. Empty means nothing on this project has a rate.
visibility* "public" | "private" Who may see the project. public is the whole organization.
viewerMayWrite* boolean
memberCount* integer
color* string | null #228be6
icon* ResourceIcon
iconInheritedFrom* VisualProvenance
colorInheritedFrom* VisualProvenance
archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
closedThroughDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

ProjectAdditionPeriod

periodStartMonth* string A calendar month, YYYY-MM. 2026-08
lastMonth* string The period’s last month, inclusive. 2026-08
state* "ready" | "running" 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 The proposal the joined hours land in; null while none exists yet. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectSeconds* integer The joining project’s own open seconds in this period, from fromMonth on: its entries’ and its allowance months’.
projectFixedPriceItemCount* integer 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 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 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 Billable seconds among them that no rate prices.
createsInvoice* boolean 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

currency* string
earliestMonth* string 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. 2026-08
latestMonth* string | null The gap’s last month, inclusive, when coverage follows it; the join ends there. null when none does. 2026-08
fromMonth* string The month this preview starts from. 2026-08
suggestedFromMonth* string | null The oldest month between earliestMonth and latestMonth with open time on the project; null when there is none. 2026-08
olderOutstandingSeconds* integer 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 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[]

ProjectAdditionResult

rule* BillingRule
proposalIds* string (uuid)[] The ended periods’ proposals that now hold the project’s open hours.
invoiceIds* string (uuid)[] The drafts created, in period order. Empty unless createInvoices.
skippedProposalIds* string (uuid)[] 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string Website Relaunch
aliases* string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
rules* RateRule[] The project’s billing rules, least specific first. Empty means nothing on this project has a rate.
visibility* "public" | "private" Who may see the project. public is the whole organization.
viewerMayWrite* boolean
memberCount* integer
color* string | null #228be6
icon* ResourceIcon
iconInheritedFrom* VisualProvenance
colorInheritedFrom* VisualProvenance
archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
closedThroughDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
hoursThisMonthSeconds* integer All tracked seconds this month, billable or not — an entry still running has none yet.
unbilled? object | null 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
unbilled.amountMinor* integer
unbilled.currency* string ISO-4217 currency code. EUR
unbilled.unratedSeconds* integer
lastActivityAt* string (date-time) | null Most recent time entry’s start, across everyone. null = never tracked. 2026-07-29T09:00:00.000Z
running* boolean Somebody has a timer running on this project.

ProjectHours

projectId* string (uuid)
projectName* string
customerId* string (uuid)
customerName* string
totalSeconds* integer

ProjectionChange

id* string
month* string A calendar month, YYYY-MM. 2026-08
changedAt* string (date-time) When this document last changed — the stamp of the run that changed it. 2026-07-29T09:00:00.000Z
deleted* boolean 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

ProjectionDocument

id* string <month>:<entryId>:<fromSecond>. Stable in its start, its month and its uniqueness — not in its end: a moved allocation boundary is a tombstone plus a new id. 2026-07:0f4d…:0
month* string 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. 2026-08
entryId* string (uuid)
fromSecond* integer Start offset within the entry, in seconds — the coordinate an invoice claim uses, so it survives an admin moving startedAt.
toSecond* integer Exclusive end offset.
seconds* integer
startedAt* string (date-time) This stretch's start, not the entry's. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) This stretch's end, not the entry's. 2026-07-29T09:00:00.000Z
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personName* string
taskId* string (uuid)
taskName* string
projectId* string (uuid)
projectName* string
customerId* string (uuid)
customerName* string
billable* boolean 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 The pot the allocation put these seconds in, or null when no budget covers them.
budgetName* string | null
rateMinor* integer | null Hourly rate in minor units. null means nothing priced these seconds — never 0 (invariant 19).
amountMinor* integer | null 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 ISO-4217 currency code. EUR
status* "provisional" | "final" | "written-off" 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. provisional
invoiceId* string (uuid) | null 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string Website Relaunch
aliases* string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
rules* RateRule[] The project’s billing rules, least specific first. Empty means nothing on this project has a rate.
visibility* "public" | "private" Who may see the project. public is the whole organization.
viewerMayWrite* boolean
memberCount* integer
color* string | null #228be6
icon* ResourceIcon
iconInheritedFrom* VisualProvenance
colorInheritedFrom* VisualProvenance
archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
closedThroughDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
hoursThisMonthSeconds* integer All tracked seconds this month, billable or not — an entry still running has none yet.
unbilled? object | null 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
unbilled.amountMinor* integer
unbilled.currency* string ISO-4217 currency code. EUR
unbilled.unratedSeconds* integer
lastActivityAt* string (date-time) | null Most recent time entry’s start, across everyone. null = never tracked. 2026-07-29T09:00:00.000Z
running* boolean Somebody has a timer running on this project.
members* object[] 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) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

ProjectMember

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) The assigned person. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

ProjectSetup

customerId* string (uuid) The customer the project is for. 404 when you cannot see it. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project* object The project.
project.name* string
project.aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
project.visibility? "public" | "private" Who may see the project. public is the whole organization.
project.hourlyRateMinor? integer The project’s default hourly rate in minor units, as one rule from the beginning. Not together with rateTemplateProjectId. 9900
project.rateTemplateProjectId? string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project.tasks? object[] The tasks to create. No two may share a name, compared case-insensitively.
project.personIds? string (uuid)[] The people to assign. You are assigned anyway, named here or not.

ProjectSetupResult

project* Project
droppedRateRules* DroppedRateRule[]

ProjectWriteOffCut

currency* string ISO-4217 currency code. EUR
entries* WriteOffCut | unknown | null
allowance* AllowanceWriteOffCut | unknown | null

ProposalInvoicePreview

ProposalPeriod

lastDay* string The last day this invoice reads, inclusive — the day a close would have to reach. 2026-08-14
projects* object[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.projectName* string
projects.closedThroughDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
notBillable* InvoiceExclusion | unknown | null 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[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
uncoveredProjects.projectName* string
uncoveredProjects.openSeconds* integer
uncoveredProjects.openFixedPriceItemCount* integer 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 Whether this proposal’s rule can take the project over a month of the period — POST /billing-rules/{id}/project-additions.
unallocatedSeconds* integer 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

object | object

PutIcon

contentType* "image/webp" | "image/png" Must match what the bytes actually are; a mismatch is rejected. image/webp
data* string Base64-encoded image, at most 192 KiB decoded. Square, 32–1024 pixels per edge. UklGRtQCAABXRUJQVlA4WAoAAAAQAAAA/wAA/wAA...

PutRateRules

RateExposureMonth

month* string A calendar month, YYYY-MM. 2026-08
trackedSeconds* integer 79200
invoicedSeconds* integer Of those, the seconds a live invoice claims. What makes a change refusable. 36000

RateRule

id* string (uuid)
taskId* string (uuid) | null The task this rule is for, or null for any task.
personId* string (uuid) | null The person this rule is for, or null for anybody. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId* string (uuid) | null 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 The month this rule’s version starts in, or null for "from the beginning". The next version replaces it whole. 2026-08
hourlyRateMinor* integer Hourly rate in minor units of the organization currency. Never null: no rate is the absence of a rule. 8500

RateRules

data* RateRule[]
exposureByMonth* RateExposureMonth[]
pricingBudgets* PricingBudget[]
namedBudgets* RetiredPricingBudget[]

RecentTask

task* object
task.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
task.name* string
task.displayName* string Server-selected task label, composed from displayNameParts in the same priority order. Acme GmbH · Frontend
task.displayNameParts* string[] One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. ["Acme GmbH","Frontend"]
task.isBillable* boolean
task.icon* ResourceIcon
project* object
project.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project.name* string
project.color* string | null #228be6
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
isBillable* boolean
billableRateMinor* integer | null Hourly rate in minor units of the organization currency. Always hourly, never a day rate. 8500
rateAxes* ("taskId" | "personId" | "budgetId")[] | null 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. ["taskId"]
currency* string ISO-4217 currency code. EUR
lastBookedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
entryCount* integer 12

RecordedDocumentInput

number* string | null
documentDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
dueDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
url* string | null
sentDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14
paidDay* string | null A calendar day, YYYY-MM-DD. 2026-08-14

RecordedOutstanding

openMinor* integer 1250000
openCount* integer 5
overdueMinor* integer 340000
overdueCount* integer 1
withheldCount* integer 0

RedeemInviteCodeRequest

code* string An invite code, in any casing or spacing — normalized server-side. TIMJ-AAAA-AAAA

RefinePlanningAllocation

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
weekStartDay* string 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. 2026-09-14
source* object The existing target whose hours are reduced.
source.projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
source.customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
source.budgetId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
destination* object The target receiving the hours. Must belong to the same customer; a project and budget must be linked.
destination.projectId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
destination.customerId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
destination.budgetId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
moveSeconds* integer Whole seconds to move, at most the source hours and at most 604800 seconds at the destination in total.
expectedSourceSeconds* integer The source plannedSeconds from the last overview read.
sourceAllocationId* string (uuid) The id of the source allocation from the last overview read. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
expectedSourceUpdatedAt* string (date-time) The source allocation updatedAt from the last overview read; a changed revision is refused. 2026-07-29T09:00:00.000Z

ReleaseExternalSettlement

ReportHint

source* string Identifier of the collector that reported this, e.g. claude-code or browser. Lowercase slug, chosen by the collector. claude-code
externalId? string | null Stable id from the collector, e.g. a session id.
clientHintId? string | null The reporting app’s own idempotency key for this report.
sessionKey? string | null Groups this report with the same session’s other reports; every report is its own observation.
subject* string Short label: what the collector thinks the person was working on.
path? HintPath
details? string | null Optional longer context — a URL, the files touched, the prompt.
startedAt? string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt? string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
signal? "presence" | "absence"

ResourceIcon

url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
contentType* "image/webp" | "image/png"
width* integer 256
height* integer 256
byteSize* integer 8452
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
checksum* string a3c9…

ResumeBillingRule

restartFromPeriod? string | null A chosen period to resume from, becoming the rule’s new generation anchor. Omitted resumes from where it left off. 2026-08

RetaskTimeEntries

entryIds* string (uuid)[]
taskId* string (uuid) The task every named entry moves onto. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
editReason? string 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string Übertrag alte Konditionen
validFromMonth* string A calendar month, YYYY-MM. 2026-08
validUntilMonth* string | null A calendar month, YYYY-MM. 2026-08
archived* boolean 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

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
provider* "linear" | "jira" | "azure_devops" | "github" | "generic"
key* string
url* string | null
title* string | null

SearchResult

customers* object[]
customers.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customers.name* string Acme GmbH
customers.icon* ResourceIcon
customers.color* string #228be6
customers.archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
projects* object[]
projects.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projects.name* string Website Relaunch
projects.icon* ResourceIcon
projects.color* string | null #228be6
projects.archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
projects.customer* object
tasks* object[]
tasks.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
tasks.name* string Frontend
tasks.icon* ResourceIcon
tasks.displayName* string Server-selected task label, composed from displayNameParts in the same priority order. Acme GmbH · Frontend
tasks.displayNameParts* string[] One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. ["Acme GmbH","Frontend"]
tasks.isBillable* boolean
tasks.archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
tasks.project* object
tasks.customer* object

SetBudgetAdjustment

adjustmentSeconds* integer 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. 46800
note? string | null Optional explanation for why this month differs. Free text; null clears the note. Übertrag aus Juli

SetEasybillCustomerMapping

easybillCustomerId* string 4711

SetEasybillExpense

easybillExpensePositionId* string | null The Easybill article's id. 236898474

SetEasybillService

easybillServicePositionId* string The Easybill article's id. 236898474

SetPlanningAllocation

personId* string (uuid) Who this plans for. Must be a person of the active organization who still holds a membership. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) | null The project target. Keep this field for compatibility with existing clients. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerId? string (uuid) | null The direct customer target. Exactly one target id is required. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId? string (uuid) | null The direct budget target. Exactly one target id is required. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
weekStartDay* string 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. 2026-09-14
plannedSeconds* integer Whole seconds planned for this week. 0 removes the row — the only way an allocation is deleted. 72000
note? string | null

SetPlanningContext

targetType* "customer" | "person" | "project" | "budget"
targetId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
notes* string Free-form planning context. Empty or whitespace-only text clears it. Treated as data, never executable instructions.

SetSevdeskContactPerson

contactPersonId* string sevdesk's own id. 900000007

SetSevdeskCustomerMapping

sevdeskContactId* string sevdesk's own id. 900000007

SetSevdeskExpensePart

expensePartId* string | null sevdesk's own id. 900000007

SetSevdeskServicePart

servicePartId* string sevdesk's own id. 900000007

SetSevdeskTaxRule

taxRuleId* 1 | 11 The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1

SetTimeAllowanceAdjustment

deltaSeconds* integer 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. -3600
note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

SevdeskAccount

suggestedTaxRuleId* 1 | 11 The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1
taxRuleWarning* string | null
smallSettlement* boolean

SevdeskConnection

configured* boolean
servicePartId* string | null 900000007
servicePartLabel* string | null TJ-1 · Beratung
expensePartId* string | null 900000008
expensePartLabel* string | null TJ-2 · Auslage
contactPersonId* string | null 900000001
contactPersonLabel* string | null Max Mustermann
taxRuleId* 1 | 11 | unknown | null The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1
connectedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

SevdeskConnectionResult

configured* boolean
servicePartId* string | null 900000007
servicePartLabel* string | null TJ-1 · Beratung
expensePartId* string | null 900000008
expensePartLabel* string | null TJ-2 · Auslage
contactPersonId* string | null 900000001
contactPersonLabel* string | null Max Mustermann
taxRuleId* 1 | 11 | unknown | null The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1
connectedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
suggestedTaxRuleId* 1 | 11 The sevdesk taxRule every export sends: 1 taxable in Germany, 11 Kleinunternehmer (§19). 1
taxRuleWarning* string | null sevdesk meldet für dieses Konto die Kleinunternehmerregelung (§19), gewählt ist aber Regel 1.

SevdeskContactList

data* object[]
data.id* string 900000003
data.name* string | null Muster GmbH
data.customerNumber* string | null 1000
data.categoryId* string | null sevdesk’s category of the contact: 3 is a customer, 2 a supplier; others are not captured. 3
page* integer 1
pages* integer 1

SevdeskContactPersonList

data* object[]
data.id* string 900000001
data.name* string | null Max Mustermann
data.role* string | null admin

SevdeskCustomerMapping

sevdeskContactId* string 900000003
sevdeskContactName* string | null Muster GmbH

SevdeskCustomerMappingList

data* SevdeskCustomerMapping & object[]

SevdeskPartList

data* object[]
data.id* string 900000007
data.partNumber* string | null TJ-1
data.name* string | null Beratung
data.vatPercent* number | null 19
mayHaveMore* boolean

SlackAuthorizeUrl

url* string (uri) https://slack.com/oauth/v2/authorize?…

SlackConnection

configured* boolean
workspace* object | null
workspace.teamId* string T0123ABCD
workspace.teamName* string Suora
workspace.connectedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
identity* object | null
identity.slackUserId* string U0123ABCD
identity.includeMessageText* boolean
identity.statusEnabled* boolean
identity.statusTemplate* string | null ⏱️ {project}
identity.statusEmoji* string | null :stopwatch:
identity.presenceEnabled* boolean
identity.huddleEnabled* boolean

SplitTimeEntry

parts* object[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
parts.startedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
parts.endedAt* string (date-time) 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. 2026-07-29T09:00:00.000Z
editReason? string 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

data* TimeEntry[]

StartTimeEntry

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
notes? string | null
startedAt? string (date-time) 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. 2026-07-29T09:00:00.000Z
stopRunning? boolean
editReason? string 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

endedAt? string (date-time) 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. 2026-07-29T09:00:00.000Z

SupersedeBudget

replaceIds* string (uuid)[] 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 The new budget that replaces every replaceIds budget in the same transaction.
successor.customerId* string (uuid) The customer that owns this budget. Every projectIds entry and referenced overflow budget must belong to this customer. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.name* string A human-readable name for the agreement; it remains stable when the project composition changes. Acme Retainer
successor.validFromMonth* string The first calendar month covered by the budget, inclusive. 2026-08
successor.validUntilMonth* string | null The last calendar month covered by the budget, inclusive. null keeps it open-ended. 2026-08
successor.budgetedSeconds* integer 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. 540000
successor.kind? "monthly" | "total" 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. monthly
successor.overflowIntoId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.overflowFromId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.projectIds* string (uuid)[] One or more project IDs covered by the budget. Every project must belong to customerId.

SupersedeTimeAllowance

replaceIds* string (uuid)[] 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 The agreement that takes over, written in the same transaction.
successor.personId* string (uuid) Who the allowance is for. Must be a person of the active organization who still holds a membership. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.taskId* string (uuid) 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). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
successor.secondsPerMonth* integer The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. 7200
successor.validFromMonth* string 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. 2026-09
successor.validUntilMonth* string | null 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. null
successor.note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

Task

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string Frontend
displayName* string Server-selected task label, composed from displayNameParts in the same priority order. Acme GmbH · Frontend
displayNameParts* string[] One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. ["Acme GmbH","Frontend"]
billingMode* "hourly" | "fixed_price" | "productive" | "internal" 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 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
fixedPriceAgreement? TaskFixedPriceAgreement
isBillabledeprecated* boolean Deprecated, use billingMode. Derived: billingMode === "hourly", so it is false for every other mode, including work that is billed but not by the hour.
isProductivedeprecated* boolean 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 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 #228be6
icon* ResourceIcon
iconInheritedFrom* VisualProvenance
colorInheritedFrom* VisualProvenance
archivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

TaskFixedPriceAgreement

id* string (uuid) 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string React-Workshop Okt 2026

TaskNameSuggestion

name* string The spelling most of the organization’s tasks with this name use. Meetings
billingMode* "hourly" | "fixed_price" | "productive" | "internal" The billing mode most of these tasks carry, as on a task.
isBillabledeprecated* boolean Deprecated, use billingMode. Derived: billingMode === "hourly", so it is false for every other mode, including work that is billed but not by the hour.
isProductivedeprecated* boolean 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 How many projects carry the name — what the suggestions are ranked by.

TicketDayReportRow

day* string 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. 2026-09-30
provider* "linear" | "jira" | "azure_devops" | "github" | "generic"
key* string TIM-199
url* string | null
title* string | null
totalSeconds* integer
carriedMinutes* integer | null
entryCount* integer
personIds* string (uuid)[]

TicketReportRow

provider* "linear" | "jira" | "azure_devops" | "github" | "generic"
key* string TIM-199
url* string | null
title* string | null
totalSeconds* integer
entryCount* integer
personIds* string (uuid)[]
firstStartedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastStartedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

TimeAllowance

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) The person the allowance is for — usually not the person who agreed it. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personName* string Frieda Sowade
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskName* string Slack-Begleitung
projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string
customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string
secondsPerMonth* integer The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. 7200
validFromMonth* string 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. 2026-09
validUntilMonth* string | null 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. null
note* string | null
projectArchivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
customerArchivedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
adjustments* object[] Every month of this agreement somebody corrected, oldest first.
adjustments.month* string A calendar month, YYYY-MM. 2026-08
adjustments.deltaSeconds* integer 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. -3600
adjustments.note* string | null
billedMonths* string[] 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. ["2026-08"]
createdByPersonId* string (uuid) | null Who agreed it. Null once that person was deleted — the row outlives them. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
updatedByPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

TimeAllowanceAdjustment

allowanceId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
month* string A calendar month, YYYY-MM. 2026-08
deltaSeconds* integer 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. -3600
note* string | null

TimeEntry

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated* string | null 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
notes* string | null
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
durationSeconds* integer 5400
isRunning* boolean
isBillable* boolean
billableRateMinor* integer | null Hourly rate in minor units of the organization currency. Always hourly, never a day rate. 8500
currency* string ISO-4217 currency code. EUR
rateAxes* ("taskId" | "personId" | "budgetId")[] | null 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. ["taskId"]
billableAmountMinor* integer | null 12750
rateSlices* object[] 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 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
rateSlices.rateMinor* integer | null Hourly rate in minor units of the organization currency. Always hourly, never a day rate. 8500
rateSlices.amountMinor* integer | null 8500
rateSlices.rateAxes* ("taskId" | "personId" | "budgetId")[] | null 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. ["taskId"]
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

TimeEntryList

data* TimeEntryWithContext[]
nextCursor* string | null Cursor for the next page, or null when the list is exhausted.

TimeEntryLogEntry

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
action* "created" | "updated" | "deleted" | "reference.attached" | "reference.detached"
recordedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
mergedIntoId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
actorPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
actorUserIddeprecated* string | null Deprecated: the better-auth account behind actorPersonId. It falls with the ?userId= query alias. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
actorName* string | null
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated* string | null Deprecated: the better-auth account behind personId. It falls with the ?userId= query alias. null where the person has no account. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
notes* string | null
demoDataSetId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskName* string | null
projectName* string | null
customerName* string | null
reason* string | null
externalReference* RevisionExternalReference

TimeEntryLogHead

timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
notes* string | null
taskName* string | null
projectName* string | null
customerName* string | null

TimeEntryRevision

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
timeEntryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
action* "created" | "updated" | "deleted" | "reference.attached" | "reference.detached"
position* integer
recordedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
mergedIntoId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
actorPersonId* string (uuid) | null Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
actorName* string | null
actorUserIddeprecated* string | null 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated* string | null 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
notes* string | null
demoDataSetId* string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskName* string | null
projectName* string | null
customerName* string | null
reason* string | null
externalReference* RevisionExternalReference

TimeEntryWithContext

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated* string | null 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
notes* string | null
startedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
endedAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
durationSeconds* integer 5400
isRunning* boolean
isBillable* boolean
billableRateMinor* integer | null Hourly rate in minor units of the organization currency. Always hourly, never a day rate. 8500
currency* string ISO-4217 currency code. EUR
rateAxes* ("taskId" | "personId" | "budgetId")[] | null 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. ["taskId"]
billableAmountMinor* integer | null 12750
rateSlices* object[] 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 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
rateSlices.rateMinor* integer | null Hourly rate in minor units of the organization currency. Always hourly, never a day rate. 8500
rateSlices.amountMinor* integer | null 8500
rateSlices.rateAxes* ("taskId" | "personId" | "budgetId")[] | null 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. ["taskId"]
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
task* object
task.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
task.name* string
task.displayName* string Server-selected task label, composed from displayNameParts in the same priority order. Acme GmbH · Frontend
task.displayNameParts* string[] One to three server-selected customer, project or task names in display-priority order. Clients must not reorder or supplement them. ["Acme GmbH","Frontend"]
task.isBillable* boolean
project* object
project.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
project.name* string
project.color* string | null #228be6
project.icon* ResourceIcon
project.iconInheritedFrom* VisualProvenance
project.colorInheritedFrom* VisualProvenance
customer* object
customer.id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customer.name* string
customer.color* string #228be6
customer.icon* ResourceIcon
updatedAtPrecise* string
harvestExport* object | null
harvestExport.harvestTimeEntryId* string
harvestExport.syncedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
harvestExport.origin* "export" | "import" 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
settlement.settledSeconds* integer 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. 5400
settlement.claims* object[] In fill order within the entry, like rateSlices. Never empty — an entry nothing settles carries settlement: null instead.
policy* EntryWritePolicy
externalReferences* ExternalReference[]

TimeProgress

currency* string
total* TimeRangeProgress
throughToday* TimeRangeProgress & unknown
perDay* DayProgress[] Every day in the range, oldest first, including days after today.

TimeRangeProgress

trackedSeconds* integer Finished, tracked seconds in the range.
billableSeconds* integer Of trackedSeconds, how much was recorded on a billable task.
nonBillableSeconds* integer
unratedSeconds* integer Billable seconds no rule priced — reported, never billed at zero (invariant 19).
billableAmountMinor* integer 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 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
targetSeconds* integer 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 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 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

running* TimeEntryWithContext | unknown | null
recentTasks* RecentTask[]
completedTodaySeconds* integer
completedBillableTodaySeconds* integer
dailyTargetSeconds* integer
capturedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

TimerStart

taskId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

TimerStopResult

running* TimeEntryWithContext | unknown | null
recentTasks* RecentTask[]
completedTodaySeconds* integer
completedBillableTodaySeconds* integer
dailyTargetSeconds* integer
capturedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
stopped* TimeEntryWithContext | unknown | null The entry this request stopped, with its final duration, or null when nothing was running.

TransferInvoice

recordedDocument? RecordedDocumentInput

UnbilledCustomer

customerId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
customerName* string Acme GmbH
icon* object | null
icon.url* string (uri) Absolute, signed URL of the image. Expires; refetch the resource to get a fresh one. https://timjami.suora.cloud/api/v1/icons/eyJvIjoi....Ad9v1Q
icon.contentType* "image/webp" | "image/png"
icon.width* integer 256
icon.height* integer 256
icon.byteSize* integer 8452
icon.updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
icon.checksum* string a3c9…
archived* boolean
seconds* integer Billable seconds with a resolvable rate, no live invoice claim over them — what amountMinor is built from. 10800
amountMinor* integer Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view. 25500
currency* string ISO-4217 currency code. EUR
unratedSeconds* integer 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. 3600
allowanceSeconds* integer 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. 7200
fixedPriceItemCount* integer 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. 2
fixedPriceAmountMinor* integer 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). 710000
upcomingFixedPriceItemCount* integer 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. 1
upcomingFixedPriceAmountMinor* integer The amount of those upcoming items. Part of fixedPriceAmountMinor, named so a screen can mark it as upcoming instead of owed. 350000
otherCurrencyFixedPriceItemCount* integer 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. 0
oldestEntryAt* string (date-time) | null 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. 2026-07-29T09:00:00.000Z
projects* UnbilledProject[]

UnbilledProject

projectId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectName* string Website Relaunch
archived* boolean 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[] 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) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
joinableRules.ruleName* string
seconds* integer Billable seconds with a resolvable rate, no live invoice claim over them — what amountMinor is built from. 10800
amountMinor* integer Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view. 25500
currency* string ISO-4217 currency code. EUR
unratedSeconds* integer 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. 3600
allowanceSeconds* integer 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. 7200
fixedPriceItemCount* integer 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. 2
fixedPriceAmountMinor* integer 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). 710000
upcomingFixedPriceItemCount* integer 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. 1
upcomingFixedPriceAmountMinor* integer The amount of those upcoming items. Part of fixedPriceAmountMinor, named so a screen can mark it as upcoming instead of owed. 350000
otherCurrencyFixedPriceItemCount* integer 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. 0
oldestEntryAt* string (date-time) | null 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. 2026-07-29T09:00:00.000Z

UnbilledSummary

currency* string ISO-4217 currency code. EUR
throughMonth* string | null 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). 2026-08
total* UnbilledTotal
customers* UnbilledCustomer[]

UnbilledTotal

seconds* integer Billable seconds with a resolvable rate, no live invoice claim over them — what amountMinor is built from. 10800
amountMinor* integer Rounded per (project, rate) group from whole seconds, as an invoice line would be (invariant 16) — not per entry like the week view. 25500
currency* string ISO-4217 currency code. EUR
unratedSeconds* integer 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. 3600
allowanceSeconds* integer 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. 7200
fixedPriceItemCount* integer 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. 2
fixedPriceAmountMinor* integer 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). 710000
upcomingFixedPriceItemCount* integer 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. 1
upcomingFixedPriceAmountMinor* integer The amount of those upcoming items. Part of fixedPriceAmountMinor, named so a screen can mark it as upcoming instead of owed. 350000
otherCurrencyFixedPriceItemCount* integer 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. 0

UpdateAbsence

kind? "vacation" | "sick" | "holiday" | "other"
fromDay? string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay? string A calendar day, YYYY-MM-DD. 2026-08-14
secondsPerDay? integer | null
fractionOfDay? number 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. 0.5
note? string | null
editReason? string 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

delayDays* integer Calendar days after a proposal’s due date before the global recipient joins the rule’s own responsible people, and before weekly repeats begin. 7
recipientPersonId* string (uuid) | null The second-level recipient every escalated or weekly-repeated proposal email also reaches. null leaves the obligation visibly unset rather than silently dropped. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

UpdateBillingRuleSchedule

name? string A human-readable name for the rule.
dueDayOfMonth? integer The due day, 1–31, in the month after the period ends. Clamped to the actual last day of a short month. 10
selectionMode? "period-only" | "cumulative" 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" The default invoice grouping a proposal generated under this rule freezes.
responsiblePersonIds? string (uuid)[] The people who receive due/escalation email for this rule.

UpdateBudget

name? string
validFromMonth? string A calendar month, YYYY-MM. 2026-08
validUntilMonth? string | null A calendar month, YYYY-MM. 2026-08
budgetedSeconds? integer 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. 540000
kind? "monthly" | "total" 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. monthly
overflowIntoId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectIds? string (uuid)[]

UpdateCapacityAgreement

validFromDay? string A calendar day, YYYY-MM-DD. 2026-08-14
kind? "share" | "fixed" 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 50.00
soldSecondsPerWeek? integer | null
employment? "internal" | "external" internal (employed) or external (freelancer).
inTeamMetric? boolean
note? string | null

UpdateCustomer

name? string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
code? string | null
color? string #228be6

UpdateDashboard

name? string
visibility? "private" | "shared" 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

UpdateFixedPriceAgreement

name? string

UpdateFixedPriceItem

agreementId? string (uuid) | null Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
description? string
quantityHundredths? integer
unit? string
unitPriceMinor? integer
easybillPositionId? string | null 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. 236898474
days? string[]
people? object[]
people.personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
people.weight* integer The share of the item’s revenue, relative to the other people. Equal by default. 1
editReason? string 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

calendarIds? string[] Which calendars are observed. An empty array is valid and means nothing is read — there is no value meaning "all". ["anna@suora.com"]
includeDetails? boolean 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

notes* string | null

UpdateMemberDailyReport

dailyOrgReportEmailEnabled? boolean
dailyOrgReportSendWeekdays? integer[]
dailyOrgReportSendHour? integer

UpdateOrganizationFeatureSwitches

planning? boolean
customer-portal? boolean
tickets? boolean
absences? boolean
planningReadConsent? "ask-again" | "extend" 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

currency? string ISO-4217 currency code. EUR
rateDisplay? "hourly" | "daily" How rates are presented in the UI. Does not affect stored data. hourly
hoursPerDay? string
timeZone? string
weekStartsOn? integer
hintRetentionDays? integer Days a hint is kept before POST /hints prunes it. Enforced when a collector reports, not by a scheduler. 30
runningTimerAvatarsEnabled? boolean
tourEnabled? boolean
jamiEnabled? boolean
llmHintEvaluationEnabled? boolean

UpdateProject

name? string
aliases? string[] Alternative names. They are included in resource search and when filtering task pickers. ["Acme Corp","ACME"]
visibility? "public" | "private" Who may see the project. public is the whole organization.
color? string | null #228be6

UpdateSlackIdentity

includeMessageText* boolean 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

statusTemplate? string | null Placeholders {customer}, {project} and {task} are filled in. null restores the neutral default, which names no customer. ⏱️ {project}
statusEmoji? string | null null restores :stopwatch:. :stopwatch:

UpdateTask

name? string
billingMode? "hourly" | "fixed_price" | "productive" | "internal" 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 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
isBillabledeprecated? boolean Deprecated, use billingMode. true makes the task hourly. false makes it internal, or leaves it productive when it already is.
isProductivedeprecated? boolean 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 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

secondsPerMonth? integer The agreed minutes as whole seconds — 2 h/month is 7200. Positive; „no allowance" is no agreement rather than one of zero. 7200
validFromMonth? string 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. 2026-09
validUntilMonth? string | null 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. null
note? string | null Why this was agreed, in free text. null clears it. „2 h/Monat" without a note is a riddle in half a year. Slack-Begleitung, vereinbart mit Frau Meier

UpdateTimeEntry

taskId? string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
startedAt? string (date-time) 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. 2026-07-29T09:00:00.000Z
endedAt? string (date-time) | null 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. 2026-07-29T09:00:00.000Z
notes? string | null
editReason? string 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

trackingOpenMonths* integer | null null removes the limit; the setting is off by default. 2

UpdateUserPreferences

rateDisplay? "hourly" | "daily" | null | null How rates are presented in the UI. Does not affect stored data. hourly
locale? string
timeZone? string | null
llmHintEvaluationConsented? boolean
keyboardShortcutsEnabled? boolean
runningTimerAvatarsEnabled? boolean
tourEnabled? boolean
jamiEnabled? boolean
forgottenTimerEmailEnabled? boolean
forgottenTimerThresholdSeconds? integer
operatorNewUserEmailEnabled? boolean
operatorNewOrganizationEmailEnabled? boolean
organizationNewMemberEmailEnabled? boolean
dailyOrgReportEmailEnabled? boolean
dailyOrgReportSendWeekdays? integer[]
dailyOrgReportSendHour? integer
budgetAlertEmailEnabled? boolean
dayTab? "list" | "calendar" | "max" „Heute“’s tabs: list („Liste“), calendar („Kalender“) and max („Max 2.0“). calendar
hintMaxViewEnabled? boolean
hintCorrectionsEnabled? boolean
hintBoundariesEnabled? boolean
hintMinDeviationSeconds? integer
hintMinDurationSeconds? integer
hintConfirmationsEnabled? boolean
hintInboxEnabled? boolean
hintEvidenceLabelsEnabled? boolean
hintExcludedSources? string[]

UpdateWorkSchedule

validFromDay? string A calendar day, YYYY-MM-DD. 2026-08-14
kind? "weekly" | "monthly"
mondaySeconds? integer | null
tuesdaySeconds? integer | null
wednesdaySeconds? integer | null
thursdaySeconds? integer | null
fridaySeconds? integer | null
saturdaySeconds? integer | null
sundaySeconds? integer | null
monthlySeconds? integer | null
note? string | null

UpsertAbsence

personId? string (uuid) Whose absence. Defaults to the caller; naming somebody else — including moving an existing absence to somebody else — needs owner or admin. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
userIddeprecated? string 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. 9MMwtzBF5FrmCec54XqaxwdRU1LDhP17
kind* "vacation" | "sick" | "holiday" | "other"
fromDay* string A calendar day, YYYY-MM-DD. 2026-08-14
untilDay* string Inclusive. Equal to fromDay for a single day. 2026-08-14
secondsPerDay? integer | null 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 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. 0.5
note? string | null Omitting it clears an existing note, for the reason secondsPerDay gives.
editReason? string 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

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33

UsageKey

id* string
name* string | null pasture
start* string | null thyu_a1
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
lastRequestAt* string (date-time) | null ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
source* "claude-code" | "codex" The coding agent a usage key reports for. claude-code
device* string pasture

UserHours

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
totalSeconds* integer
lastEntryAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

UserPreferences

rateDisplay* "hourly" | "daily" | null | null How rates are presented in the UI. Does not affect stored data. hourly
locale* string de-DE
timeZone* string | null
llmHintEvaluationConsented* boolean Whether this person permits manual LLM evaluation of their own hints. false
keyboardShortcutsEnabled* boolean Whether keyboard shortcuts and their visual key hints are enabled. false
runningTimerAvatarsEnabled* boolean 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. true
tourEnabled* boolean 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). true
jamiEnabled* boolean 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). true
forgottenTimerEmailEnabled* boolean Whether the forgotten-running-timer rule may deliver an email. true
forgottenTimerThresholdSeconds* integer 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. 21600
operatorNewUserEmailEnabled* boolean Whether a current platform operator receives email when an account is created. false
operatorNewOrganizationEmailEnabled* boolean Whether a current platform operator receives email when an organization is created. false
organizationNewMemberEmailEnabled* boolean Whether a current organization owner or admin receives email when a member joins. false
dailyOrgReportEmailEnabled* boolean Whether a current holder of report.readOthers receives the daily organization revenue digest. false
dailyOrgReportSendWeekdays* integer[] ISO weekdays (1 = Monday … 7 = Sunday) the digest is sent on. The covered period is derived from this schedule, not stored separately. [1,2,3,4,5]
dailyOrgReportSendHour* integer 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. 7
budgetAlertEmailEnabled* boolean 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. false
dayTab* "list" | "calendar" | "max" | null | null 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. calendar
hintMaxViewEnabled* boolean 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. false
hintCorrectionsEnabled* boolean Whether „Heute“ marks a finished entry that this person's own hints contradict, with a one-click correction. true
hintBoundariesEnabled* boolean Whether „Heute“ offers presence observed before the first or after the last entry of the day as the start or end of the workday. true
hintMinDeviationSeconds* integer On „Heute“, an entry’s own task continuing past it, a workday edge or a correction shorter than this many seconds is not offered. 900
hintMinDurationSeconds* integer 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. 0
hintConfirmationsEnabled* boolean Whether „Heute“ names the hint sources that agree with a finished entry. false
hintInboxEnabled* boolean Whether „Heute“ lists hint subjects that are not classified yet. true
hintEvidenceLabelsEnabled* boolean Whether a hint row on „Heute“ names its sources and subjects as text, or shows only their icons. true
hintExcludedSources* string[] Hint sources „Heute“ leaves out. Presentation only: the hints themselves and GET /hints are unaffected. ["browser"]

UserProjectCount

personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
projectCount* integer

UserSession

id* string
current* boolean
expiresAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
ipAddress* string | null
userAgent* string | null
clientType* string

UserSessions

UserSession[]

VisualProvenance

kind* "project" | "customer"
id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
name* string

WatchCredential

token* string
expiresAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

WidgetPushToken

platform* "ios" | "watchos"
kind* string
environment* "sandbox" | "production"
lastSeenAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

WidgetPushTokenRegistration

platform* "ios" | "watchos"
kind* string
environment* "sandbox" | "production"
token* string

WorkSchedule

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
validFromDay* string The day this version starts holding. The next version ends the previous one — there is no validUntilDay. 2026-08-14
kind* "weekly" | "monthly" weekly: the seven weekday fields are the agreement. monthly: monthlySeconds is, and a working day’s own share is derived.
mondaySeconds* integer | null
tuesdaySeconds* integer | null
wednesdaySeconds* integer | null
thursdaySeconds* integer | null
fridaySeconds* integer | null
saturdaySeconds* integer | null
sundaySeconds* integer | null
monthlySeconds* integer | null Set for kind: monthly only.
note* string | null
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
updatedAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z

WriteOff

id* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
reason* string Why these hours were waived. Required when the batch is created. Projekt nach Vorarbeit abgesagt — Kulanz
createdAt* string (date-time) ISO-8601 timestamp with offset. 2026-07-29T09:00:00.000Z
releasedAt* string (date-time) | null Set once the write-off was taken back. The record stays and stays listed; its seconds went back to „Abzurechnen". 2026-07-29T09:00:00.000Z
createdByPersonId* string (uuid) | null Null once the person row itself was deleted — the record outlives them. Deleting the account leaves the person, and this id, in place. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
createdByName* string | null
currency* string The claims’ frozen currency, not the organization’s today: changing the currency revalues un-invoiced hours and leaves settled records where they are. EUR
seconds* integer
amountMinor* integer
entryCount* integer
claimCount* integer
rates* WriteOffRateTotal[] 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

entryId* string (uuid) Resource identifier (UUID v4). 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
seconds* integer The seconds this claim waives — the free stretch of the entry that this rate priced, never the entry’s whole duration.
rateMinor* integer 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 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) The entry’s start, for the row’s label. 2026-07-29T09:00:00.000Z
personId* string (uuid) Person identifier (UUID v4) — a person, not a login. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personName* string
customerName* string
projectName* string
taskName* string

WriteOffCut

currency* string ISO-4217 currency code. EUR
seconds* integer Everything waived, over every rate.
amountMinor* integer The sum of the per-rate rows, which are sums of per-claim amounts.
entryCount* integer How many entries contribute at least one claim.
rates* WriteOffRateTotal[]
claims* WriteOffClaim[]

WriteOffList

data* WriteOff[]
nextCursor* string | null Cursor for the next page, or null when the list is exhausted.

WriteOffRateTotal

rateMinor* integer
seconds* integer
amountMinor* integer 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

WritePolicy

permission* "free" | "reason" | "locked" free — nothing is in the way. reason — allowed, and the save must carry editReason. locked — refused for this caller whatever they type.
blockers* PolicyBlocker[]

WriteRateRule

taskId? string (uuid) | null The task this rule is for. Omit or null for any task. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
personId? string (uuid) | null The person this rule is for. Omit or null for anybody. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
budgetId? string (uuid) | null 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. 7f3f1d0e-6d1a-4f5e-9b1a-2f8c1a4b6d33
validFromMonth? string | null 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. 2026-08
hourlyRateMinor* integer Hourly rate in minor units. Never null — leave the rule out instead. 9900

Generated from the domain OpenAPI document of this server, v0.1.0, on every request. Better Auth generates its linked Auth & organizations document independently. Nothing on this page is a committed artefact, so nothing on it can be stale.