Skip to content
GitRiverGitRiver
RU
Navigation

The /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

GitRiver’s own interface answers at /api/v1. This document is a commitment: what in the responses counts as the contract and doesn’t change, what must not be changed, how deprecation is announced, and how long a deprecated call keeps working.

The commitment exists for anyone who writes against the interface once and lives with that code for years: export and migration scripts, report templates, deployment services, third-party adapters.

The promise takes effect as of version 1.1.0 (September 25, 2026) — the first release to carry it. Everything that 1.1.0 answers with is, by definition, the contract. One version shipped before it — 1.0.0 (March 29, 2026); everything that changes in the move from that version is named by name at the end of this document.

What v1 means

The number in the path is the contract version, not the product version. The product grows (1.0 → 1.1 → onward), the path stays /api/v1: a new minor version of the server requires not a single line of change from the client.

The server version is reported separately and must be requested explicitly:

Where What it returns
GET /health build version and database state: status (ok/error) and version
GET /api/openapi.json, field info.version the build version of the server that served the spec
GET /api/v1/auth/me the edition of the installation the caller is running under: instance_is_pro, instance_is_max, is_pro, license_grace_until

Only a caller with a token learns the edition. GET /api/v1/server-info answers without a token and returns the settings the interface needs before sign-in — the external address, whether SSH is enabled and the clone-URL prefix, whether profiles are visible to anonymous users; it carries no version and no edition.

Part of the contract

Property Example
Call path and method PATCH /api/v1/repos/{owner}/{name}/issues/{number}
Success response code 201 for creation, 204 for deletion, 200 for reads and actions, 202 for accepted work
Required response body fields and their types id, number, status on an issue
Error envelope shape {"error": "<text>", "code": "<machine code>"} — both fields are always present
Machine error code values not_found, ref_not_found, unauthorized, forbidden, conflict, validation, internal, bad_gateway, too_large, license_required, rate_limited, registration_closed
Pagination parameter and result field names for a given call per_page, after in the request; items, next_cursor, total in the response
Token requirement a call that answered anonymous requests keeps answering anonymous requests
Edition gating a call that worked in Community doesn’t become paid; a call for which the installation’s license was enough doesn’t start requiring a Pro seat; a call doesn’t start requiring a higher edition. This rule applies as of 1.1.0; the tightening made in the move from 1.0.0 is named by name at the end of this document

The names of path placeholders ({owner}, {name}) don’t change the client’s address, but they do end up in the spec and from there in any client generated from it — so we don’t rename those either.

The shape of responses and the pagination rules are described in the user guide; this document doesn’t say how they’re built, only what part of that we commit not to change.

The contract covers the interface, not the installation’s settings. The server owner is free to close off profiles, set up restrictions by network origin, or revoke permissions: an anonymous caller will see less, but that’s the owner’s decision, not a change to the contract.

Not part of the contract

  • Key order in JSON objects.
  • Element order in lists whose sort order isn’t declared. A declared order is part of the contract (discussion threads run oldest to newest).
  • Text: the error field, operation descriptions in the spec, interface labels. Text gets translated and refined; code relies on code. The language of the text is chosen by the request’s Accept-Language header (ru and en are available now); without the header or with an unavailable language — the instance language (the language setting). The language of a refusal is named in Content-Language.
  • The shape of opaque strings. next_cursor is an opaque string: it must be returned as-is in the next request. Its contents must not be parsed — the encoding may change.
  • Limit values: page size, rate thresholds, maximum body size. These are configured by the server owner and vary from installation to installation. The contract is the behavior at the boundary, not the number: page size and offset are clamped to the allowed range (?limit=-1 returns a single record, not a 400), a body over the limit gets 413, a request over the rate gets 429 with Retry-After.
  • Response time, identifier numbers, log contents.

Where there is no error envelope

The {"error", "code"} envelope comes with refusals from API calls — including for a nonexistent path under /api/v1 (404 with code not_found) and for exceeding the rate limit (429, which also carries a retry_after field and the headers Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining).

Three refusals may arrive without an envelope: 405 — the path exists, the method doesn’t; 504 — the response deadline expired; a cutoff for a body that’s too large in a multipart file upload (multipart/form-data). These may have no body at all, and the client should rely on the status code instead. Here the contract is the status code itself, not whatever is (or isn’t) in the body.

When accepting an artifact or cache archive or a release attachment, 413 arrives with the usual envelope and code too_large. Parsing errors for a JSON body, including 413, also arrive with the shared envelope (see below).

What must not change

Every row here is something that silently breaks a client already written against the API.

Forbidden What the client sees
Remove a path 404 on a call that used to work
Remove a method from a path, including a deprecated alias 405 on a call that used to work
Rename or drop a required response field a missing key, a KeyError in a script
Change a field’s type parsing fails or computes the wrong thing
Change the success response code a client generated from the spec treats the response as unexpected
Rename a machine error code or change the response code for an error kind the handling branch stops matching
Require a token where anonymous requests used to get answered 401 instead of data
Make a parameter required that used to be optional 400 on a request that used to work
Narrow the accepted input values, tighten validation what used to be accepted stops being accepted
Start charging for a call that worked in Community 403 with license_required where it worked in Community
Start requiring a Pro seat where the installation’s license was enough 403 with license_required for an account without a seat
Start requiring a higher edition than before 403 with license_required for an installation with its previous license

How the promise is kept

The call path, method, success response code, token requirement, and edition gating don’t change unnoticed: a release in which a call is removed or one of these properties is altered doesn’t ship. A new call doesn’t break the promise.

Required response body fields are part of the contract too, and a missing field is a broken promise. If you notice that a field your code relies on has disappeared from the response, report it as a defect — don’t treat it as a side effect.

What’s allowed and doesn’t count as a break

  • A new path; a new method on an existing path.

  • A new optional field in a successful response. There’s already an example of such an addition: the partial field — a “data is incomplete” caveat, today with a single value "truncated" (the result was cut off by a limit). A client that doesn’t know about the field reads the response as before; the field’s absence means the response is complete.

    The incompleteness caveat also exists outside the envelope. For a file in a diff (files[].truncated) and for a file view (truncated) it applies to a SINGLE object, not to the whole result: it’s not the list that got cut off, but the content of one particular file that crossed a size limit (currently 100 KiB per file in a diff and 10 MB for a file view). The flag is raised only for reasons of SIZE and is not conflated with is_binary: a large text file isn’t reported as binary. The third reason for an empty patch — content didn’t change (a rename, a permission change) — raises neither flag.

  • A new optional request parameter, as long as behavior without it stays the same.

  • A new value in an enumeration — a machine error code or a partial-result reason. Hence the requirement on the client: an unfamiliar value must be handled by falling back on the HTTP status code and must not crash processing. This growth is announced in advance: there will be more partial-result reasons.

  • Loosening input validation, widening the accepted values.

  • Changing text, key order, the order of an unsorted result.

  • Rejecting an unknown field in the request body. In 1.0.0 a field the call doesn’t know was dropped silently: a repository-creation request with "visibility": "public" instead of "is_private": false returned 201 and created the repository as private. The client got success and not the result it asked for. This isn’t counted as tightening the contract for exactly this reason: requests with unknown fields never worked the way their author expected — now they say so. The rejection is 400 with code validation; the text names the unrecognized fields by their full path from the body’s root.

    The rule applies within our own /api/v1 interface — including the paths served by the setup wizard. The compatibility layers (/api/v3, /api/v4), SCIM, OIDC, Git LFS, and the package registry protocols accept unfamiliar fields silently: the request shape there is set by an external specification.

    The runner protocol (/api/v1/runner/**) is also outside the rule: a runner newer than the server keeps working with it.

Body-parsing errors arrive with the shared envelope

An unreadable or malformed /api/v1 request body responds with 400 and the envelope {"error", "code": "validation"} — the same as any other failed input validation: broken JSON, a missing required field, the wrong value type, a repeated key. If the error concerns a specific field, the text names it.

In 1.0.0 a body that parsed but had the wrong shape responded 422, and none of the parsing errors carried a machine code. This isn’t a change of code for an error kind: the “body doesn’t parse” kind of error was never part of the contract in the first place — there was no code to identify it by either. A client that used to tell this case apart by 422 now identifies it by the code validation, alongside every other input-validation failure.

One case keeps a code of its own: a body sent without Content-Type: application/json responds 415 — the server never got as far as the content. Its envelope and code are the same.

Parsing errors for path and query PARAMETERS arrive with the same envelope, code validation, and text naming the parameter. The status code is 400, as in 1.0.0; the machine code was added.

The query string did NOT become strict, and never will: an unfamiliar parameter is skipped silently. A link that picked up extra parameters along the way (tracking tags, counters, a proxy’s return address) keeps working.

Intentional tightenings

The forbidden-changes table calls “tightening validation” a break — and that’s the rule. Exceptions to it are made by name, and only where the previous behavior ANSWERED SUCCESS FOR SOMETHING THAT DIDN’T HAPPEN — that is, where the contract was already not being honored:

Call Before After Why this isn’t a break
PATCH /api/v1/users/{username} 200 and no change to the record if is_admin or is_pro_seat was sent without the rights for it 403 with a list of the fields The fields were never applied before either; the client believed the action had gone through
PUT /api/v1/repos/{owner}/{name}/environments/{env} the environment name from the body was ignored, taken from the path instead 400 if the name in the body doesn’t match the path A request with a different name modified the ENVIRONMENT FROM THE PATH, while the client believed it had created a second one
POST/PATCH for a SAML provider an empty sp_entity_id was accepted 400 An empty identifier breaks sign-in, and that was only discovered on the first sign-in attempt, not when saving
POST /api/v1/repos/{owner}/{name}/packages/retention, POST /api/v1/repos/{owner}/{name}/packages/tag-rules an empty mask and a negative “keep last”/“delete older than” value were accepted 400 A rule with an empty mask matches no image and no tag, and a negative number is read as “no limit.” The record was created, answered 201, and did nothing: the client believed retention was configured
POST /api/v1/admin/k8s-runners omitting runner_image was accepted and produced gitriver-runner:latest 400 The cluster looks for the short name on Docker Hub, doesn’t find it, and leaves the pod in ImagePullBackOff. The setting was created, answered 201, and could NEVER bring up a runner: the client believed autoscaling was configured. For updates (PUT) the field is still optional — there, omitting it means “don’t change”, while an empty address answers 400 for the same reason
POST/PUT /api/v1/admin/k8s-runners an empty or Kubernetes-invalid namespace and an empty match_label were accepted 400 With an empty namespace the controller addressed /namespaces//jobs; with an empty label it matched no job. The setting answered 201, and a runner never came up

What all of these share: a request that stopped being accepted never did what was expected of it in the first place. A tightening that has no such explanation is still forbidden.

Deprecation: how it’s announced and what the client sees

A deprecated operation is marked in the /api/openapi.json spec with deprecated: true and a description of where to move to. The /api/docs page shows the marker, and a client generator working from the spec carries it into the generated code.

All PUT aliases for partial updates are marked this way: the canonical method is PATCH, and PUT on the same path is left 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. Listing an external system’s repositories requires a token for accessing it, and a token in the address ends up logged by every intermediary along the way: the full query string gets written 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 method POST, 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 are no Deprecation (RFC 9745) or Sunset (RFC 8594) headers in the response. They warn of a shutdown date, and within v1 a deprecated call is never shut down (see below) — there’s no date to warn about.

How long a deprecated call keeps working

Within v1, exactly as long as v1 itself. The deprecated mark means “there’s a canonical way now, write new code against that,” not a countdown to shutdown: removing a method is forbidden by the rule above, and that rule applies to deprecated aliases the same as to everything else.

Something can only be removed together with a change to the contract number — by introducing /api/v2. When that happens:

  1. v2 is announced in the release notes and in this document, with a list of differences and a migration path;
  2. /api/v1 keeps responding for at least twelve months from the release date of the version that introduced /api/v2;
  3. during that period v1 gets security fixes under the general rule in security.md; no new capabilities are added to it;
  4. v1’s end date is named at the moment v2 is announced, not later.

An installation that upgrades once a year catches both versions working at least once. This period is longer than the support window for the previous minor version (six months).

What lives under someone else’s version numbers

The promise covers only /api/v1. Below is everything whose contract we don’t write; there we commit to following someone else’s spec, and to following it wherever it changes.

Address Whose contract
/api/v3 GitHub’s interface (coverage)
/api/v4 GitLab’s interface (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

The compatibility layers live under other platforms’ version numbers: v3 and v4 in these paths are their versions, not ours. Our promise doesn’t extend to them; theirs does — “the response matches the other platform’s response” — and what’s supported and what isn’t is named in the coverage documents.

A separate case is the native package protocols (npm, PyPI, Cargo, Maven, NuGet, Composer, Generic). They live UNDER /api/v1, for example GET /api/v1/packages/{owner}/{name}/cargo/index/config.json. The paths are ours, and the “paths aren’t removed” rule covers them. But the shape of the body — both success and error — is set by the package manager: it changes to follow that manager’s spec, and the shared {"error", "code"} envelope doesn’t apply there.

Edition split in 1.1.0

What changed. ENTERPRISE capabilities — SAML SSO sign-in and its settings, external account management over SCIM, LDAP, the audit log, quotas, IP-address rules, branding, knowledge-draft limits — are now unlocked by the Max edition. In 1.0.0 any active Pro license unlocked them. The affected calls:

Capability Calls
SAML SSO sign-in /api/v1/admin/saml/providers and /api/v1/admin/saml/providers/{id} (all methods); every call under /api/v1/auth/saml/
SCIM every call under /api/v1/admin/scim/
LDAP /api/v1/admin/ldap (all methods), POST /api/v1/admin/ldap/test
Audit log GET /api/v1/admin/audit-log
Quotas PUT /api/v1/admin/quotas, PUT /api/v1/admin/quotas/users/{id}, PUT /api/v1/admin/quotas/groups/{id} — reading and deleting quotas remain unrestricted by edition
IP-address rules POST /api/v1/admin/ip_rules, PATCH and PUT /api/v1/admin/ip_rules/{id}
Branding PUT /api/v1/admin/branding
Knowledge-draft limits PUT /api/v1/admin/knowledge/limits/users/{id}, PUT /api/v1/admin/knowledge/limits/groups/{id}

The assistant’s settings stayed in the Pro edition.

Who this affects. An installation with a Pro license that used anything on this list. The calls respond 403 with code license_required until the edition is changed to Max. Everything else that’s paid — security checks, license-compliance scanning, DORA metrics, the code index, custom roles and group variables, the assistant along with all of its settings — is still unlocked by the Pro edition, and nothing changed for it.

What this means when buying a license. The number of seats bought doesn’t affect the availability of enterprise capabilities: only the Max edition unlocks them.

Why this is named here. The “edition gating isn’t tightened” rule takes effect as of 1.1.0, and this tightening was made in the move from 1.0.0 — before the rule took effect. A change that takes a capability away from a paid installation is still named by name, together with the list of affected calls and the migration path. Changes like this are never made silently.

Migration path. For the owner of a Pro-licensed installation who needs the enterprise capabilities, the publisher issues a Max-edition activation file under the same license ID — the term doesn’t have to move. The installation accepts that file and responds “License updated: edition raised from Pro to Max”; seats aren’t doubled, and no second row appears in the license list. It’s entered in the same place as a renewal (licensing), and it arrives the same way, automatically, from the license server.

The installation takes the edition from the signature, not from what was bought and when. So a re-issue done on the publisher’s side arrives on its own — as the next check-in, with no action from the administrator — and entering a file by hand is only needed where check-ins can’t get through (an air-gapped deployment) or when the change needs to take effect immediately. The terms of sale — who’s entitled to which edition and from what date — aren’t described here: the installation carries out whatever is signed.

License 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 license term. As long as signatures keep arriving — as a check-in to the license server or as a manually entered file — nothing changes. When signatures stop arriving, paid calls start responding 403 with code license_required, even though the license term is still running.

Why this isn’t a broken promise. No 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 the same way. 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 has a license.

Who this affects. Clients written on the assumption that 403 license_required is impossible for an installation with an active license. That was never actually promised — a license can be removed from the administration panel at any moment — but now there’s a second way to run into it. The fix is the same: show the user the rejection text instead of treating it as a failure.

What was added to responses. /api/v1/admin/license now returns the fields lease_until, lease_days_left, lease_expired, and db_moved for each license, and lease_warning, lease_expired, db_moved, db_fingerprint in the summary; the activity feed gained the value license_lease_expiring. Both are additions allowed by the list above; a client that doesn’t know about them reads the response as before.

Details — licensing.

What changes in the move from 1.0.0 to 1.1.0

1.0.0 shipped before this promise existed, and the move to 1.1.0 is the one place where something changes that is declared unchanging from here on. So everything is named here at once: what the interface client sees, and what the owner of a paid installation sees. This list doesn’t grow after this.

The response shape became paginated in three places. Instead of a top-level array, {"items": [...], "next_cursor": ...} arrives:

  • issue comments, pull request comments and reviews;
  • a repository’s packages and one package’s versions;
  • quota summary lists — GET /api/v1/admin/quotas/users and GET /api/v1/admin/quotas/groups.

Details and the migration path are in the user guide.

We don’t do this going forward: an existing response’s shape doesn’t change, and pagination, if it’s needed somewhere else, gets its own separate call.

Enterprise capabilities moved to the Max edition — see edition split above: the list of affected calls and the migration path are there.

Licenses are confirmed for a limited time — see license confirmation: an installation that signatures stop reaching gets 403 license_required before its license term ends.

Seats are counted. An installation with a SEAT-COUNT license will find that paid sections are unlocked only for members who have the seat flag set — see licensing. Installations with an unlimited-seat license aren’t affected.

null in an update clears the value. In a partial update an omitted field is left unchanged, and null clears an optional field: an issue’s milestone, a milestone’s due date and description, the description of a label, release or repository, a cluster certificate, the service account of a Kubernetes runner configuration. In 1.0.0 null in these fields did nothing. For PUT /api/v1/admin/k8s-runners/{id}, omitting service_account now keeps the previous value, like the other fields — in 1.0.0 it erased it; to clear it, pass null or an empty string.