Steerd API

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.version is 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 / :write scope 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:

  • Deprecation header (RFC 8594) on affected responses.
  • Sunset header (RFC 8594) once a removal date is scheduled.
  • The OpenAPI document marks deprecated operations with deprecated: true; GraphQL uses the @deprecated directive.

Support window

After a new major version reaches stable release, the previous major is supported for at least 6 months before its sunset date:

  1. v(N+1) ships and is documented as stable.
  2. vN responses begin carrying Deprecation (and, once scheduled, Sunset) headers.
  3. vN keeps serving its unchanged contract for ≥ 6 months from the v(N+1) stable date.
  4. On or after the Sunset date, vN may 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 at POST /api/public/v1/oauth/register (RFC 7591), and the consent page at /oauth/authorize. POST /api/public/v1/oauth/token now 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 with strdo_, lasts 30 days, and works on the MCP endpoint only: REST and GraphQL refuse it as an unknown key. The MCP endpoint's 401 now carries a WWW-Authenticate challenge naming its resource metadata and the scopes a connection gets, with error="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_ref to the time-entry resource (REST and MCP; the GraphQL field is externalRef): 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 answers 200 with the stored entry where a create that wrote answers 201, GraphQL returns the stored entry, and MCP's create_time_entry returns it with already_logged: true. A caller who may not read the stored entry is refused with the code already_logged and nothing else: REST answers 409, GraphQL returns it in errors[].extensions.code, and MCP in the tool error's code. The field is optional on create, null on every entry created without one, and freed when its entry is deleted. An update never changes it: REST's PATCH ignores the field, and GraphQL's UpdateTimeEntryInput has no externalRef. GET /time-entries and GraphQL timeEntries gain the matching filter, and MCP list_time_entries gains date_from and date_to, both inclusive. Additive; a create without the field behaves as before. See Idempotency.

  • 1.7.0: adds a read-only invoices resource: GET /invoices (filter by status and type), GET /invoices/:id (its recipient address includes region, the state or province) and GET /invoices/:id/pdf for the sealed PDF, plus the GraphQL invoices connection and invoice(id). One new scope token, invoices:read; there is no invoices: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 as not_found on REST and both resolve to null on 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, and GET /assignments/:id, plus the matching GraphQL singulars (task(id), activity(id), assignment(id)) and MCP getters (get_task was already present; get_activity and get_assignment join it). A missing id and another team's id both 404 as not_found on REST and both resolve to null on GraphQL. Additive; existing keys are unaffected.

  • 1.5.0: adds organization_cleared_at to the time-entry resource (REST and MCP; the GraphQL field is organizationClearedAt). 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 with organization_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 cursor that will not decode is now 400 bad_request instead 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 with hasMore: true and a fresh nextCursor, and the ordinary paging loop ran forever with nothing on either side reporting a fault. An absent or empty cursor still 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 cursor has always been documented as an opaque value taken from a previous page.nextCursor. The neighboring case, a cursor that decodes but names a different sort or order, 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 documents resource to files. /documents becomes /files, and the documents:read / documents:write scope tokens become files: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 no trips:write and 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 documents and attachments resources (REST-only: list, get/download, multipart upload, delete) with new documents:read / documents:write and attachments:read / attachments:write scope tokens. Uploads are multipart and not idempotent. Additive; existing keys are unaffected until re-issued with the new scopes. (Renamed to files in 1.3.0; the names here are the ones 1.2.0 actually shipped.)

  • 1.1.0: adds the time_entries resource (REST + GraphQL: list, get, create, update, delete, and a range summary) with new time_entries:read / time_entries:write scope tokens. Additive; existing keys are unaffected until they are re-issued with the new scope.

  • 1.0.0: initial v1 surface (organizations, contacts, projects, employees, cvs, tasks, activities, assignments, search), with cursor pagination, idempotency, API-key authentication, OpenAPI + Swagger UI, and the GraphQL interface.

On this page