Navigation
/api/v1 Compatibility Promise
What /api/v1 means, what counts as a contract in responses, what must never change, how deprecation is announced and how long a deprecated call lives
This translation is pending approval by the vendor. The binding text is the Russian original, published at gitriver.ru/docs/api-compatibility and shipped with the product as
docs/api-compatibility.md. Where the two differ, the Russian text prevails.
GitRiver’s own API answers at /api/v1. This document is a commitment: what in
the responses counts as a contract and does not change, what must never be
changed, how deprecation is announced, and how long a deprecated call lives.
The commitment is for those who write against the API once and live with what
they wrote for years: export and migration scripts, report templates, deployment
services, third-party adapters (RiverMCP talks only to /api/v1 and uses nothing
internal). Without a written promise there was nothing to rely on but our good
faith.
The promise takes effect with version 1.1.0 (18 September 2026) — the first release that carries it. Everything 1.1.0 answers is the contract. Before it there was one release, 1.0.0 (29 March 2026); what changes when moving from it is named at the end of this document, item by item.
What v1 means
The number in the path is the version of the contract, not the version of the
product. The product grows (1.0 → 1.1 → onwards), the path stays /api/v1: a
new minor version of the instance does not require a single line of change from
the client.
The instance version is reported separately and must be requested explicitly:
| Where | What it returns |
|---|---|
GET /health |
the build version and the database state: status (ok/error) and version |
GET /api/openapi.json, field info.version |
version of the build that served the specification |
GET /api/v1/auth/me |
the edition of the installation the caller works in: instance_is_pro, instance_is_max, is_pro, license_grace_until |
The edition is reported to the caller and not to an anonymous visitor, and that
is a decision: GET /api/v1/server-info answers without a token, and the
edition in its response would announce the state of the installation’s licence
to anyone who opened the instance address. server-info itself returns the
settings the interface needs before sign-in - the external address, whether SSH
is enabled and the clone address prefix, whether profiles are visible to
anonymous visitors - and carries neither the version nor the edition.
Part of the contract
| Property | Example |
|---|---|
| Call path and method | PATCH /api/v1/repos/{owner}/{name}/issues/{number} |
| Success status code | 201 for creation, 204 for deletion, 200 for reads and actions, 202 for accepted-for-processing |
| Required response body fields and their types | id, number, status on an issue |
| Shape of the error envelope | {"error": "<text>", "code": "<machine code>"} — both fields are always present |
| Values of the machine error codes | not_found, ref_not_found, unauthorized, forbidden, conflict, validation, internal, bad_gateway, too_large, license_required, rate_limited, registration_closed |
| Names of pagination parameters and result fields on a given call | per_page, after in the request; items, next_cursor, total in the response |
| Token requirement | a call that answered anonymous callers keeps answering anonymous callers |
| Edition membership | a call that worked in Community does not move behind the edition gate; a call for which an instance licence was enough does not start requiring a Pro seat; a call does not start requiring a higher edition. The rule is in force from 1.1.0; the tightening made in the move from 1.0.0 is named at the end of this document, item by item |
The names of the placeholders in a path ({owner}, {name}) do not change the
address the client uses, but they do appear in the specification and from there in
any client generated from it — which is why we do not rename them either.
The shape of responses and the pagination rules are described in the user guide; what is stated here is not how they work, but what we commit not to change.
The contract is about the API, not about how an installation is configured. The instance owner may close profiles, set restrictions by network origin, or revoke permissions: an anonymous caller will then see less, but that is the owner’s decision, not a change of contract.
Not part of the contract
- The order of keys in JSON objects.
- The order of elements in lists for which no sort order is declared. A declared order is part of the contract (a discussion runs from older to newer).
- Texts: the
errorfield, operation descriptions in the specification, interface labels. Text gets translated and refined; a program relies oncode. - The internal structure of opaque strings.
next_cursoris an opaque string: it must be passed back as is in the next request. Parsing its contents is not allowed, its encoding may change. - The values of limits: page size, rate thresholds, maximum body size. They
are configured by the instance owner and differ from installation to
installation. The contract is the behaviour at the boundary, not the number:
page size and offset are clamped to the allowed range (
?limit=-1returns one record, not a400), a body over the limit gets413, a request over the rate limit gets429withRetry-After. - Response time, identifier numbering, log contents.
Where there is no error envelope
The {"error", "code"} envelope is produced by API handlers — including on a
nonexistent address under /api/v1 (404 with code not_found) and on rate
limiting (429, which also carries the retry_after field and the Retry-After,
X-RateLimit-Limit, X-RateLimit-Remaining headers).
Errors that come without an envelope are those produced not by handlers but by
the router and the shared layers: 405 — the path exists, the method does
not; 504 — the response deadline expired; the abort of an over-large body
where the limit is counted by a layer rather than by a handler (a file upload sent
as a multipart body). These may have no body at all, and the client should rely on
the status code. The contract here is the status code itself, not what does or
does not sit in the body.
Where the handler itself counts the limit (accepting an artifact or cache archive,
a release attachment), 413 arrives in the usual envelope with code too_large.
A JSON body is another exception: it is parsed by the API’s own extractor, and all
parsing errors, 413 included, arrive in the common envelope (see below).
What must never change
Every line is something that silently breaks a client that is already written.
| Forbidden | What the client sees |
|---|---|
| Remove a path | 404 on a call that worked |
| Remove a method from a path, including a deprecated alias | 405 on a call that worked |
| Rename or remove a required response field | a missing key, KeyError in a script |
| Change the type of a field | parsing fails, or reads the wrong thing |
| Change the success status code | a client generated from the specification treats the response as unexpected |
| Rename a machine error code, or change the status for a class of error | the handling branch stops being selected |
| Require a token where anonymous callers were answered | 401 instead of data |
| Make a previously optional parameter required | 400 on a request that worked |
| Narrow the accepted input values, tighten validation | what used to be accepted stops being accepted |
| Move a working call behind the edition gate | 403 with license_required where it worked in Community |
| Require a Pro seat where an instance licence was enough | 403 with license_required for an account without a seat |
| Require a higher edition than was required before | 403 with license_required for an installation holding its previous licence |
How this is enforced
Four properties from that list — path, method, success code, token requirement —
plus the edition gate are recorded as a snapshot in the source tree. The gate
column names BOTH of its axes, the edition and the unit of measure: без-шлюза
(no gate), шлюз-pro (Pro edition, not measured in seats), шлюз-pro-место (Pro
edition and a seat held by the caller), шлюз-max (Max edition). The snapshot
lives in crates/gitriver-api/src/routes/manifest/api_v1_surface.txt, one line
per operation. A change that removes a call or alters those properties fails the
build and names exactly what was removed or changed. Adding a call does not break
the promise, but the snapshot must stay complete — otherwise tomorrow’s call is
protected by nothing — so the build requires a new line to be added to it.
The limits of the check are stated plainly: the snapshot does not see the set of response body fields. A removed field is a break of the promise, but it is caught by review, not by the build.
What is allowed and does not count as a break
-
A new path; a new method on an existing path.
-
A new optional field in a successful response. There is already an example of such an addition: the
partialfield, a caveat meaning “the data is incomplete”, today with a single value"truncated"(the result set was cut by a limit). A client that does not know the field reads the response as before; the absence of the field means a complete response.A caveat about incompleteness can also be NON-envelope. On a file in a diff (
files[].truncated) and on file viewing (truncated) it refers to a SINGLE object rather than to the result set: what was cut is not the list but the contents of a particular file that crossed the limit (MAX_DIFF_FILE_SIZE,MAX_BLOB_SIZE). The flag is raised for reasons of SIZE only and is not conflated withis_binary: before it existed, both reasons for missing contents were explained by that one flag, and a large text file was passed off as binary. The third reason for an empty patch — the contents did not change (a rename, a permission change) — raises neither of the two flags. -
A new optional query parameter, provided behaviour without it is unchanged.
-
A new value in an enumeration — of a machine error code or of a partiality reason. Hence the requirement on the client: an unfamiliar value must be handled by the HTTP status code and must not break processing. The growth is announced in advance: there will be more partiality reasons.
-
Relaxing input validation, widening the range of accepted values.
-
Changing text, key order, or the order of an unsorted result set.
-
Rejecting an unknown field in the request body. A field the call does not know used to be dropped silently: a request to create a repository carrying a visibility field named the way other platforms name it returned
201and created a private repository, because the real field is called something else. The client got a success and not the result it had asked for. This is not counted as a tightening of the contract for exactly that reason: requests with unknown fields never did what their author expected — now they say so. The rejection is400with codevalidation; the text names the unrecognised fields by their full path from the root of the body.The rule applies to GitRiver’s own API,
/api/v1— including the addresses served by the setup wizard. The compatibility layers (/api/v3,/api/v4), SCIM, OIDC, Git LFS and the package registry protocols accept unknown fields silently: there the shape of the request is set by someone else, and they are entitled to extend it.For the same reason the runner protocol (
/api/v1/runner/**) is outside the rule as well, even though the addresses are ours: a runner is upgraded separately from the instance, and a runner newer than the instance would be rejected on every status update. The body there is composed by our own code, so a typo in a field name has nowhere to come from.
A body that cannot be parsed comes back in the common envelope
A request body to /api/v1 that is unreadable or of the wrong shape is answered
with 400 and the envelope {"error", "code": "validation"} — like every other
failed validation of input. Previously the status depended on the class of failure:
broken JSON gave 400, while JSON that parsed but had the wrong shape (a missing
required field, a wrong value type, a repeated key) gave 422; both arrived with
an English-language message from the library (Failed to deserialize the JSON body into the target type: missing field ...), that is, without a machine code. The
client could neither branch on it nor translate the message.
This is not a change of status for a class of error: the class “the body does not
parse” was not in the contract at all — nor was there a code by which to recognise
it. A client that used to tell this case apart by 422 now recognises it by the
code validation, together with the rest of input validation.
One case kept a status of its own: a body sent without
Content-Type: application/json is answered with 415 — the server never got as
far as the contents. The envelope and the code are the same.
Parsing failures on path and query PARAMETERS changed in the same way: an
unreadable identifier used to be answered with 400 and a library message
(“Invalid URL: Cannot parse abc to a Uuid”), and is now answered with the same
envelope, the code validation and a text that names the parameter. The status is
unchanged; what was added is the machine code.
The composition of the query string has NOT become strict and will not: an unknown parameter is ignored silently. An address with parameters gets forwarded as a link, and foreign parameters are appended along the way — campaign tags, counters, an intermediary’s return marker; rejecting those would hit ordinary use, not a typo.
Deliberate tightenings
The table of prohibitions calls “tighten validation” a break — and that is the rule. Exceptions to it are made item by item and only where the previous behaviour ANSWERED SUCCESS TO SOMETHING NOT DONE, that is, where the contract was not being honoured in the first place:
| Call | Was | Is now | Why this is not a break |
|---|---|---|---|
PATCH /api/v1/users/{username} |
200 and an unchanged record if is_admin or is_pro_seat was sent without the right to set it |
403 listing the fields |
The fields were not applied before either; the client considered the action done |
PUT /api/v1/repos/{owner}/{name}/environments/{env} |
the environment name in the body was ignored, the one from the address was used | 400 if the name in the body differs from the one in the address |
A request with a different name edited THE ENVIRONMENT FROM THE ADDRESS, while the client believed it had created a second one |
POST/PATCH of a SAML provider |
an empty sp_entity_id was accepted |
400 |
An empty entity id breaks sign-in, and it used to be discovered at the first sign-in attempt rather than at saving |
What all three have in common: the request that stopped being accepted did not do what was expected of it before either. A tightening without such an explanation remains forbidden.
Deprecation: how it is announced and what the client sees
A deprecated operation is marked in the /api/openapi.json specification with
deprecated: true and a description of where to move. The /api/docs page shows
the marker, and a client generator carries it over into the generated code.
All PUT aliases for partial update are marked this way: the canonical method is
PATCH, while PUT on the same path is kept working for the sake of scripts
already written. For example:
PATCH /api/v1/users/{username} canonical
PUT /api/v1/users/{username} deprecated
PATCH /api/v1/repos/{owner}/{name}/issues/{number} canonical
PUT /api/v1/repos/{owner}/{name}/issues/{number} deprecated
PATCH /api/v1/repos/{owner}/{name}/pull_requests/{number} canonical
PUT /api/v1/repos/{owner}/{name}/pull_requests/{number} deprecated
GET /api/v1/import/remote-repos is marked the same way, for a different reason.
The list of repositories on an external system is requested with an access token
for that system, and a token in the address settles in the logs of every
intermediary on the way: the full query string is written down by the reverse
proxy, the load balancer and the access server, and those logs outlive the
migration itself. The canonical call is the same path with POST, the token in
the body:
POST /api/v1/import/remote-repos canonical
GET /api/v1/import/remote-repos?auth_token=… deprecated
The second channel is the release notes and the user guide.
There is no Deprecation header in the response, and that is a decision, not an
omission. The point of such a header (RFC 9745) is to warn about a shutdown date,
which is announced alongside it (Sunset, RFC 8594). Inside v1 there is no
shutdown (see below), there is nothing to announce, and a header without a date
would tell the client something untrue.
How long a deprecated call lives
Inside v1 — as long as v1 itself. The deprecated marker means “there is
a canonical way, write new code with it”, not a countdown to shutdown: removing a
method is forbidden by the rule above, and that rule applies to deprecated aliases
exactly as it does to everything else.
Anything can be removed only together with a change of the contract number — by
introducing /api/v2. In that case:
v2is announced in the release notes and in this document, with a list of differences and a migration procedure;/api/v1keeps answering for no less than twelve months from the release date of the version in which/api/v2appeared;- during that period
v1receives security fixes under the general rule of SECURITY.md; no new capabilities are added to it; - the end-of-life date for
v1is named at the momentv2is announced, not later.
Twelve months is a period sized for a customer’s annual upgrade cycle: an installation that upgrades once a year must find both versions alive at least once. It is deliberately longer than the support period of the previous minor version (six months): changing the contract number requires edits in someone else’s code, whereas a minor upgrade does not.
What lives under other version numbers
The promise covers /api/v1 only. Below is what we do not write the contract
for; there we commit to following someone else’s specification, and if it changes
— to follow it.
| Address | Whose contract |
|---|---|
/api/v3 |
the GitHub API (coverage) |
/api/v4 |
the GitLab API (coverage) |
/scim/v2 |
RFC 7644 (account provisioning) |
/v2 |
OCI Distribution Spec (image registry) |
/oauth/*, /.well-known/* |
RFC 6749, RFC 8414, OpenID Connect |
/{owner}/{repo} over git and LFS |
the git and Git LFS protocols |
Compatibility layers live under the version numbers of the foreign platforms: v3
and v4 in those paths are their versions, not ours. Our promise does not extend
to them; their own promise is “the response matches the response of the foreign
platform”, and what is supported and what is not is stated in the coverage
documents.
A separate case is the native package protocols (npm, PyPI, Cargo, Maven,
NuGet, Composer, Generic). They sit UNDER /api/v1, for example
GET /api/v1/packages/{owner}/{name}/cargo/index/config.json. The paths are ours,
and the rule “a path is not removed” applies to them. The shape of the body,
however — both of the successful response and of the error — is set by the package
manager: it changes in step with that manager’s specification, and the common
{"error", "code"} envelope does not apply there.
Edition split in 1.1.0
What changed. ENTERPRISE capabilities — SAML SSO sign-in and its settings,
external account provisioning over SCIM, LDAP, the audit log, quotas, rules by
instance address, branding, knowledge draft limits — are now unlocked by the
Max edition. In 1.0.0 any valid Pro licence unlocked them. In the surface
snapshot the gate column of those calls changed from шлюз-pro to шлюз-max.
There is a testable sign of an enterprise capability: can one purchased seat
obtain it for the whole company? The assistant settings do not fit that test —
every request to the model is measured against a seat — and stayed in the Pro
edition (the шлюз-pro column, as before).
Who is affected. An installation on a Pro licence that was using something
from that list. Those calls answer 403 with the code license_required until
the edition is changed to Max. Everything else that is paid — security scanning,
licence compliance, DORA metrics, the code index, custom roles and group
variables, the assistant in full together with its settings — is still unlocked by
the Pro edition, and for that part nothing changed.
Why it was done. A licence for ONE seat used to include enterprise sign-in, provisioning and the audit log for the entire installation: a company of three hundred people paid for one seat and got SAML for everyone. A seat measures the work of a participant, whereas SAML sign-in happens before a participant exists — a minimum seat count at purchase does not close that hole. Only the edition does.
Why it is named here. The rule “edition membership is not tightened” is in force from 1.1.0, and this tightening was made in the move from 1.0.0 — before the rule took effect. We do not intend to hide behind that: a change that takes a capability away from a paying installation is named item by item, together with the list of affected calls and the migration procedure — and all the more so later, when the rule is in force. Changes like this are not made silently, and the surface snapshot does not let them pass silently either: a changed column fails the build.
Migration procedure. For the owner of an installation on a Pro licence who needs the enterprise capabilities, the vendor issues a Max edition activation file with the same licence id — the expiry date need not move. The installation accepts such a file and answers “Licence updated: edition raised from Pro to Max”; seats are not doubled, and no second row appears in the list of licences. It is entered in the same place as a renewal (licensing), and it arrives automatically from the licence server by the same route.
The instance takes the edition from the signature, not from what was bought and when. The signed activation response carries an edition field, and the gate compares against that field alone; the instance does not parse purchase dates at all. That is why a re-issue made on the vendor’s side arrives by itself — with the next check-in, without any action by the administrator — and entering a file by hand is needed only where check-ins do not travel (an isolated network), or when the change is needed immediately. For the same reason the terms of sale — who is entitled to which edition, and from what day — are not reflected in the behaviour of the instance and are not described here: the instance honours what is signed.
Entitlement confirmation in 1.1.0
What changed. A signed activation response is now valid for a limited time
(lease_until) rather than until the end of the licence term. As long as
signatures keep arriving — by the check-in to the licence server or by a file
entered by hand — nothing changes. When they stop arriving, paid calls start
answering 403 with the code license_required even though the licence term is
still running.
Why this is not a break of the contract. Not a single path, method, parameter
or response field changed or disappeared; the error code and envelope are the same
(license_required, 403), and a client that already knows how to read them reads
them just the same. What changed is the CONDITION under which a paid capability is
unlocked, and licensing conditions were never part of the /api/v1 contract: it
promises the shape of the calls, not that the installation holds a licence.
Who is affected. Clients written on the assumption that 403 license_required
is impossible on an installation with a valid licence. There never was such a
promise — a licence can be removed from the admin area at any moment — but now
there is a second way to run into it. The remedy is the same: show the user the
text of the rejection instead of treating it as a malfunction.
What was added to responses. /api/v1/admin/license returns the fields
lease_until, lease_days_left, lease_expired and db_moved on each licence,
and lease_warning, lease_expired, db_moved, db_fingerprint in the summary;
the activity feed gained the value license_lease_expiring. Both are additions
permitted by the list above; a client that does not know about them reads the
response as before.
Details — licensing.
What changes when moving from 1.0.0 to 1.1.0
1.0.0 was released before this promise, and the move to 1.1.0 is the only place where something later declared unchangeable does change. So everything is named here at once: both what the API client sees and what the owner of a paid installation sees. This list does not grow after that.
The response shape became a page in three places. Instead of a top-level array
you get {"items": [...], "next_cursor": ...}:
- issue comments, pull request comments and reviews;
- repository packages and the versions of a single package;
- the aggregate quota lists —
GET /admin/quotas/usersand/groups.
The reason is the same for all of them: the length of the list is set by the user, not by us, and a discussion of thousands of records was being read into memory in full on every view. Details and the migration procedure are in the user guide.
We will not do this again: the shape of an existing response does not change, and paginated output, if it is needed anywhere else, is introduced as a separate call.
Enterprise capabilities moved to the Max edition — see the edition split above: the list of affected calls and the migration procedure are there.
Entitlement is confirmed for a limited time — see entitlement
confirmation: an installation that stops
receiving signatures gets 403 license_required before the licence term ends.
Seats are counted. An installation on a licence FOR A NUMBER OF SEATS will find that paid sections are open only to those participants who hold the seat flag — licensing. Installations on a licence with an unlimited number of seats are not affected.