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
codestring; - 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
codefor 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
enumfor a field that was previously described only asstring. 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 futurev2will 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 exampleDeprecation: @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 av1operation means a date inv2's lifetime, notv1'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:
| Condition | Status | code |
|---|---|---|
| Body is not well-formed JSON | 400 | invalid_json |
| Required member absent | 422 | missing_argument |
| Member of the wrong type | 422 | invalid_type |
| Body parsed but does not fit the schema | 422 | invalid_arguments |
Missing or non-JSON Content-Type | 415 | unsupported_media_type |
| Body over the size limit | 413 | request_too_large |
Where the server can name the member at fault it does, in the error's field — title, 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:
| Field | Published 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:
statusis 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_categoryis the field to branch on.kindonRelationViewaccepts a team's ownsnake_caselabels alongside the four built-in kinds, sodepends_onis a valid value.
Both now carry a description saying so.
1.0
Initial published contract. The compatibility promise above commences here.