Coming October 2026Launch offer: 25% off your first year until 30 November 2026

API stability

If you are building against Rilbo's JSON API, this page is the promise you can rely on. It says what we may change without warning, what we will never change inside /api/v1, and how you will hear about it when something is going away.

It covers the JSON API under /api/v1 and nothing else. The MCP server is a separate surface with its own tool schemas and is not versioned by this contract.

The contract version

The OpenAPI document's info.version is the contract version — currently 1.5. It is set by hand and changes only when the contract changes, so it is safe to pin.

It is not the version of the binary. That is published separately, as info.x-rilbo-build in the same document and as version from /healthz, and it moves on every commit. If you have been pinning info.version, you were pinning the build; pin it now and it will mean something.

The major part of the contract version and the 1 in /api/v1 are the same number. A breaking change means a new prefix — /api/v2 — with v1 left running. We do not break v1 in place.

What will not change within /api/v1

These are breaking, and we will not do them:

  • removing or renaming a response field;
  • changing a field's type or nullability;
  • changing the HTTP status returned for an existing condition;
  • changing or removing an error code string;
  • adding a required request field, or tightening validation on an existing one.

What may change within /api/v1

These are additive, and we will do them without a major version. Write your client so they do not break it:

  • adding a new optional request field;
  • adding a new response field — ignore fields you do not recognise;
  • adding a new endpoint;
  • adding a new error code for a genuinely new condition — match on the codes you know and treat an unrecognised code as a generic failure of its HTTP status;
  • documenting a response field's existing value set more precisely — for example publishing an enum for a field that was previously described only as string. The field's JSON type does not change and neither do the values we send; the schema simply stops under-describing what it already was. Note the consequence: a published value set falls under the promise above, so widening one is a breaking change and belongs in a new major version. If you generate a client from the document, generate the enum open — accept an unrecognised value rather than failing to parse — and a future v2 will not surprise you.

The contract's minor version is bumped when any of these lands.

When the promise starts

The promise above binds from contract version 1.0 onward. It is not retroactive, and we are saying so rather than quietly implying it: Rilbo shipped changes before 1.0 that this promise would forbid. The clearest example is duplicate values — a request that collided with an existing team key or label name used to come back as 500, and now correctly comes back as 409. That was the right change, and it would not be permitted today without going through the defect procedure below.

Correcting a defect

There is one narrow exception, and it is narrow on purpose.

Where the behaviour itself is a defect — a status code that misreports what happened, a response that contradicts this documentation, a validation gap that lets through data the API cannot honour — we reserve the right to correct it inside v1 rather than carry the bug for the life of a major version. Any such correction is recorded in the change log at the bottom of this page, with what changed and why it was a defect.

We do not use this to make changes we simply prefer. "The old shape was inconvenient" is not a defect.

Error codes

Every error response carries a machine-readable code. The complete set is published as x-rilbo-error-codes at the root of the OpenAPI document (GET /api/v1/openapi.json), generated from the same table the server emits from, so it is exhaustive and cannot fall out of date.

That document is the list; this page is only the rule about it. Within v1 a code may be added but is never removed, renamed, or repointed at a different condition.

The list is the whole application's set, not just the API's. Rilbo's domain logic is shared between the JSON API, the web UI and the MCP server, so a few codes in it are only reachable on one of the other two. We publish and lock the superset on purpose: it is better to list a code you will never see than to omit one you will.

Deprecation

Nothing in /api/v1 is deprecated today. When something is, this is what happens.

It keeps working. A deprecated endpoint in v1 is never switched off. The deprecation is notice that it will be absent from a future major version, not a countdown to an error.

It is marked in the OpenAPI document. The operation carries deprecated: true and a description naming what to use instead.

It announces itself on the wire. Responses from a deprecated operation carry:

  • Deprecation (RFC 9745) — the date the deprecation took effect, as a structured-field date. For example Deprecation: @1688169599. Note this is a Unix timestamp after an @, not a date string; the pre-standard drafts of this header used a date string, and parsers written against those will not read it.
  • Link: <…>; rel="deprecation" — a link back to this page.
  • Sunset (RFC 8594) — only if a date has been set for the resource becoming unavailable, which for a v1 operation means a date in v2's lifetime, not v1's. Format is an HTTP-date, e.g. Sunset: Wed, 11 Nov 2026 11:11:11 GMT.

It is announced in advance. An operation deprecated in contract version X.Y is announced in the change log below and marked from that release onward. It may be absent from the next major version only if the deprecation was announced at least two contract minor versions before that major version ships. In practice: you get at least two releases' notice, and you never get a removal inside v1 at all.

Rate limits

Every /api/v1 request passes a rate limiter, keyed on the authenticated identity rather than the client address — one busy client cannot spend another's allowance. The default quota is 600 requests per minute with a burst allowance of 60, configurable under [limits] in the config file or with RILBO_API_REQUESTS_PER_MINUTE and RILBO_API_BURST (enabled = false under [limits] switches limiting off entirely).

A refused request answers 429 with the standard error envelope (code rate_limited) and a Retry-After header giving whole seconds to wait. Every operation in the OpenAPI document declares the 429, so a generated client knows to expect it.

Requests are also bounded by a timeout — 30 seconds by default, RILBO_REQUEST_TIMEOUT_SECS — after which the server answers 504. The SSE stream at /api/v1/events is exempt, being long-lived by design. Both statuses are declared per operation in the OpenAPI document.

Change log

Entries are by contract version, newest first.

1.6

Activity and domain-event responses now include additive service provenance. service_id is the stable identifier retained with the historical row, while service is the optional current display name when that service account still exists. Existing nullable human actor fields keep their meanings, and records without service provenance are unchanged.

1.5

Added GET /api/v1/agent-audit, an administrator-only, Team-licensed governance view of agent-originated audit events. The endpoint supports exact service, credential, client, transport, approver, team, tool, action, outcome, and proposal filters; bounded date windows; and keyset pagination. Its response is a deliberately redacted projection and never includes raw payloads, diffs, network metadata, credential hashes, proposal text, or current issue titles.

1.4

A correction under the defect procedure: a request body that the server cannot deserialize now answers with the standard error envelope instead of a plain-text line.

Malformed JSON, a missing required member, a member of the wrong type, a body sent without Content-Type: application/json, and a body over the size limit are all rejected before the handler runs. Until now that rejection was written by the web framework, as text/plain, while this API's OpenAPI document declared the standard { "data", "meta", "errors" } envelope for those operations. A generated client therefore failed to parse the very error it had just been handed, and there was no code to branch on. That contradiction between the document and the wire is what makes it a defect rather than a preference.

No HTTP status changed. Each condition keeps the status it already answered with:

ConditionStatuscode
Body is not well-formed JSON400invalid_json
Required member absent422missing_argument
Member of the wrong type422invalid_type
Body parsed but does not fit the schema422invalid_arguments
Missing or non-JSON Content-Type415unsupported_media_type
Body over the size limit413request_too_large

Where the server can name the member at fault it does, in the error's fieldtitle, or a path such as links[0].kind for a nested one. Messages are fixed strings; they no longer carry the line and column offsets the framework used to include, which moved with the caller's own whitespace and could not be relied on.

request_too_large and unsupported_media_type are the two new codes this adds, which is an additive change under the rules above.

400, 413 and 415 are now declared per operation in the OpenAPI document. The server could always answer them; publishing them was not possible while the body contradicted the document.

Attachment uploads are unaffected, and the table above does not describe them. POST /api/v1/issues/{identifier}/attachments reads its own request body and keeps the behaviour it already had: an over-limit request is 413 attachment_request_too_large, and everything else — malformed JSON, a missing Content-Type, or a body of the wrong shape — is 422 invalid_attachment_body with field: "data". That operation therefore declares no 400 and no 415, because it does not answer either.

1.3

Added the personal PUT /api/v1/teams/{key}/watchers/me and DELETE /api/v1/teams/{key}/watchers/me operations. They manage whether the acting user is subscribed to future issues created in the named team. The operations require a writable session or a bound API token with write (or admin) scope; they do not retroactively change existing issue subscriptions.

1.2

Three attachment rejection codes that were already emitted by the API are now included in x-rilbo-error-codes: attachment_request_too_large, invalid_attachment_body, and invalid_attachment_form. The response codes, statuses, fields, and messages are unchanged; the published catalogue now includes the complete error surface.

Also in 1.2, a correction under the defect procedure: GET /api/v1/issues and GET /api/v1/search now refuse a query string containing a parameter they do not document, answering 400 where they previously ignored it. Silently dropping an unrecognised parameter meant a misspelling — stauts=done, say — changed the meaning of the request with no signal to the caller, who received an answer to a different question than the one they thought they had asked. Every documented parameter, including the q and category aliases, is unaffected; only requests that were already malformed see the new status.

1.1

Four response fields that were published as plain string now publish the enum of values they have always carried. Nothing on the wire changed — the same responses, byte for byte — and the JSON type of each field is still string. What changed is that the document stops under-describing them:

FieldPublished values
priority (on IssueSummary, IssueDetail, SubtreeNode, template views)none, low, medium, high, urgent
status_category (on IssueSummary, IssueDetail, ParentRef, ChildView, SubtreeNode)triage, backlog, unstarted, started, completed, canceled
blocked_source (on IssueDetail)manual, relation, both, or null
direction (on RelationView)outgoing, incoming

Two neighbouring fields deliberately did not gain an enum, because their value sets are genuinely open and always were:

  • status is a workflow state's name, which each team chooses for itself. There is no fixed list of state names and there never was one. status_category is the field to branch on.
  • kind on RelationView accepts a team's own snake_case labels alongside the four built-in kinds, so depends_on is a valid value.

Both now carry a description saying so.

1.0

Initial published contract. The compatibility promise above commences here.