Versioning & deprecation
What changes without warning, what triggers a new version, and how long a retired version keeps working.
This policy governs the stability of the Public API (/api/public/v1/*, REST and
GraphQL). It does not apply to the internal routes the first-party web app uses,
which carry no stability guarantee.
Version scheme
The Public API is versioned in the URL path:
/api/public/v1/...
/api/public/v2/... (a future major version)- The path segment (
v1,v2, …) is the major version. A new major version ships under a new path; the old path keeps serving its own contract unchanged. - The OpenAPI document's
info.versionis a semantic version whose MAJOR equals the path version (v1→1.x.x). MINOR increments on backward-compatible additions; PATCH on documentation fixes with no behavioral change. - GraphQL shares the same surface and version.
Non-breaking (within a major version)
Clients must tolerate these without breaking:
- New endpoints, queries, mutations, and resources. (A new resource also adds its
:read/:writescope tokens. Because scopes carry no wildcards, existing keys do not auto-gain access; they keep working, and an admin adds the scope to a key to opt in.) - New optional request fields, query parameters, or GraphQL arguments.
- New fields on response objects; REST clients must ignore unknown fields.
- New enum values: clients must tolerate values they do not recognize.
- New error
codes in the catalog. - Relaxed validation, and performance or wording changes.
Breaking (requires a new major version)
Never made in place; shipped under a new version path:
- Removing or renaming an endpoint, field, query parameter, enum value, or error code.
- Changing a field's type, or making an optional field required.
- Changing the shape of the pagination envelope, the cursor format, or the error envelope.
- Tightening validation so previously-accepted input is rejected.
- Changing authentication, scoping, or default behavior in an observable way.
Deprecation signaling
When something is slated for removal, we signal it well in advance:
Deprecationheader (RFC 8594) on affected responses.Sunsetheader (RFC 8594) once a removal date is scheduled.- The OpenAPI document marks deprecated operations with
deprecated: true; GraphQL uses the@deprecateddirective.
Support window
After a new major version reaches stable release, the previous major is supported for at least 6 months before its sunset date:
v(N+1)ships and is documented as stable.vNresponses begin carryingDeprecation(and, once scheduled,Sunset) headers.vNkeeps serving its unchanged contract for ≥ 6 months from thev(N+1)stable date.- On or after the
Sunsetdate,vNmay be removed.
The window is a minimum; a longer one may be granted and announced.
Changelog
v1
-
1.9.0: an MCP client can connect without an API key, through OAuth as the MCP authorization specification describes it; see Connect with OAuth. New, all on the app origin: the discovery documents
/.well-known/oauth-protected-resource/api/public/v1/mcp(RFC 9728) and/.well-known/oauth-authorization-server(RFC 8414), open client registration atPOST /api/public/v1/oauth/register(RFC 7591), and the consent page at/oauth/authorize.POST /api/public/v1/oauth/tokennow takes a form body as well as JSON and answers every error, its own failures included, in RFC 6749 shape,{ error, error_description }, instead of the API's error envelope. On a live endpoint that would be a breaking change; it is not one here, because before this release the endpoint was switched off everywhere and no client could reach it. A token issued to an MCP client starts withstrdo_, lasts 30 days, and works on the MCP endpoint only: REST and GraphQL refuse it as an unknown key. The MCP endpoint's401now carries aWWW-Authenticatechallenge naming its resource metadata and the scopes a connection gets, witherror="invalid_token"added when the request sent a credential that was refused; the body is unchanged. Additive for every existing key and client. -
1.8.0: adds
external_refto the time-entry resource (REST and MCP; the GraphQL field isexternalRef): the caller's name for the work an entry records, unique in the team. A create naming a ref the team already holds writes nothing. REST answers200with the stored entry where a create that wrote answers201, GraphQL returns the stored entry, and MCP'screate_time_entryreturns it withalready_logged: true. A caller who may not read the stored entry is refused with the codealready_loggedand nothing else: REST answers409, GraphQL returns it inerrors[].extensions.code, and MCP in the tool error'scode. The field is optional on create,nullon every entry created without one, and freed when its entry is deleted. An update never changes it: REST'sPATCHignores the field, and GraphQL'sUpdateTimeEntryInputhas noexternalRef.GET /time-entriesand GraphQLtimeEntriesgain the matching filter, and MCPlist_time_entriesgainsdate_fromanddate_to, both inclusive. Additive; a create without the field behaves as before. See Idempotency. -
1.7.0: adds a read-only
invoicesresource:GET /invoices(filter bystatusandtype),GET /invoices/:id(itsrecipientaddress includesregion, the state or province) andGET /invoices/:id/pdffor the sealed PDF, plus the GraphQLinvoicesconnection andinvoice(id). One new scope token,invoices:read; there is noinvoices:write. A key reads invoices only while the member who created it may see them in the app. A missing id and another team's id both 404 asnot_foundon REST and both resolve tonullon GraphQL. Additive; existing keys do not gain the scope until an admin adds it to them. -
1.6.0: adds
GET /tasks/:id,GET /activities/:id, andGET /assignments/:id, plus the matching GraphQL singulars (task(id),activity(id),assignment(id)) and MCP getters (get_taskwas already present;get_activityandget_assignmentjoin it). A missing id and another team's id both 404 asnot_foundon REST and both resolve tonullon GraphQL. Additive; existing keys are unaffected. -
1.5.0: adds
organization_cleared_atto the time-entry resource (REST and MCP; the GraphQL field isorganizationClearedAt). It is set when the client an entry named was deleted, which is what separates "this hour never had a client" from "this hour's client is gone" -- both serialize withorganization_id: null, so before this the two were identical over the wire. An entry carrying the stamp is not attributed to its project's client; it waits to be assigned one. The field is a timestamp and deliberately not the deleted client's id, so it carries no identity. Additive; existing clients are unaffected. -
1.4.0: a
cursorthat will not decode is now400 bad_requestinstead of silently serving the first page. Previously any unparseable value was treated as "no cursor was sent", so a client whose cursor was truncated or mangled in its own plumbing got page one back withhasMore: trueand a freshnextCursor, and the ordinary paging loop ran forever with nothing on either side reporting a fault. An absent or emptycursorstill means the first page, unchanged.This is an observable behavior change, so it gets its own entry and a MINOR bump rather than passing as a documentation patch. It is deliberately not treated as the "tightening validation" case that the rules above make breaking: the only caller who can observe it is one sending a cursor Steerd never issued, and
cursorhas always been documented as an opaque value taken from a previouspage.nextCursor. The neighboring case, a cursor that decodes but names a differentsortororder, has always been a 400. This closes the one hole in that rule rather than adding a new restriction, and it does not reuse the spent 1.3.0 exception. -
1.3.0: renames the
documentsresource tofiles./documentsbecomes/files, and thedocuments:read/documents:writescope tokens becomefiles:read/files:write. The name was given up so that "documents" can mean accounting records (Belege) in a later release, rather than arbitrary uploaded files.By the rules above this is a breaking change and would require a new major version. It was made in place, once, because it predates the first issued API key and any public launch, so there is no client that can observe it. That exception is spent: the rule stands for everything after this entry.
-
trips, added 2026-07-25, with no version of its own. A read-only trips resource over REST (/trips,/trips/{id},/trips/{id}/expenses) and MCP, with a single new scope token,trips:read. There is deliberately notrips:writeand no GraphQL field: a trip is created, submitted and approved in the app, where approval seals its numbers.The MINOR bump the rules above require was not made at the time, so this shipped inside
1.2.0, which the document had reported since 2026-07-23, and is recorded here after the fact rather than renumbered. Nothing about it was breaking, and a version number no client ever saw is not worth inventing. The rule stands; this is the entry that should have existed. -
1.2.0: adds the
documentsandattachmentsresources (REST-only: list, get/download, multipart upload, delete) with newdocuments:read/documents:writeandattachments:read/attachments:writescope tokens. Uploads are multipart and not idempotent. Additive; existing keys are unaffected until re-issued with the new scopes. (Renamed tofilesin 1.3.0; the names here are the ones 1.2.0 actually shipped.) -
1.1.0: adds the
time_entriesresource (REST + GraphQL: list, get, create, update, delete, and a range summary) with newtime_entries:read/time_entries:writescope tokens. Additive; existing keys are unaffected until they are re-issued with the new scope. -
1.0.0: initial
v1surface (organizations, contacts, projects, employees, cvs, tasks, activities, assignments, search), with cursor pagination, idempotency, API-key authentication, OpenAPI + Swagger UI, and the GraphQL interface.