Skip to content
GitRiverGitRiver
RU
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 error field, operation descriptions in the specification, interface labels. Text gets translated and refined; a program relies on code.
  • The internal structure of opaque strings. next_cursor is 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=-1 returns one record, not a 400), a body over the limit gets 413, a request over the rate limit gets 429 with Retry-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 partial field, 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 with is_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 201 and 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 is 400 with code validation; 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:

  1. v2 is announced in the release notes and in this document, with a list of differences and a migration procedure;
  2. /api/v1 keeps answering for no less than twelve months from the release date of the version in which /api/v2 appeared;
  3. during that period v1 receives security fixes under the general rule of SECURITY.md; no new capabilities are added to it;
  4. the end-of-life date for v1 is named at the moment v2 is 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/users and /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.