uVersion
English
Download →

Wiki

REST API

Reference of the uVersion server HTTP endpoints: authentication, repositories, files, locks, comments, watchlist, production board, builds, administration.

The uVersion server exposes a JSON HTTP API. Calls are authenticated with a JWT (JSON Web Token), that is, the session token the server hands you when you log in and that you then send back on every request. This page documents the endpoints used by the desktop client, the editor plugins and the uversion CLI. You can call them directly to integrate uVersion into your own internal tooling (in-house dashboard, audit scripts, webhooks, etc.).

It does not cover the entire API: several families of routes exist and are not described here. The list is in the Uncovered areas section.

Conventions

Base URL

Every documented path is relative to the URL of your instance. The examples use https://uversion.mygamestudio.com. Replace it with your own.

Required headers

HeaderValue
AuthorizationBearer <jwt> on every /api/* route except /api/auth/* and /api/server-info (and /health)
Content-Typeapplication/json for POST/PUT with a JSON body. application/octet-stream for binary uploads (build files).
Acceptapplication/json recommended (the server returns JSON by default)

Two response formats, and you need to know which one you are reading

The server does not have one response format but two, and confusing them is the costliest mistake for anyone starting an integration.

1. Authenticated routes (all /api/* except /api/auth/*) respond with an envelope:

{
  "success": true,
  "data": { /* payload */ }
}

On error, those same routes respond:

{
  "success": false,
  "error": "No write permission on this repository"
}

The success field is always present. data is present on success, error on failure, never both together.

2. The authentication routes (/api/auth/login, /refresh, /validate, /logout, /register, /change-password) do not use this envelope. They return the bare object, without success or data:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": { "id": 12, "username": "alice", ... },
  "must_change_password": false
}

And their errors are a single-field object, without success:

{ "error": "Invalid credentials" }

Practical consequence: on /api/auth/login, the token is read at .token and not at .data.token. A script that queries .data.token gets null with no visible error.

Finally, GET /api/files/{repo_id}/content returns neither: it is the raw binary content of the file, as-is.

Pagination

There is no common pagination convention: each route has its own, or none at all. Do not assume limit, total or has_more.

RouteAccepted parametersResponse shape
GET /api/files/{repo_id}/history limit (default 50), offset (default 0), path Flat array of commits. No total, no has_more: you have reached the end when the array holds fewer items than limit.
GET /api/repositories include_inactive only (and it is honored only for a super administrator) Flat array. Neither limit nor offset is read.
GET /api/locks/{repo_id}/status None Flat array of every lock in the repository.
GET /api/files/{repo_id}/snapshot commit_hash, required Flat array of files.
GET /api/admin/audit page and per_page, not limit/offset, plus filters (user_id, action, entity_type, from, to) Super-administrator only. A good illustration of the missing common convention: it is the only route that paginates by page number.

For any route not listed here, assume it returns its entire result in a single call.

Rate limiting

There is no general rate limiting: an Unreal project has thousands of files and bulk operations (acquiring, releasing locks) would trip a throttle constantly. So you can call the API at volume.

A single route is limited, /api/auth/login: 5 failed attempts per username every 15 minutes. Successful logins are not counted.

Token revocation

Every session token carries a tv field (token version), which mirrors the users.token_version column in the database. The server compares the two on every request. A call to POST /api/auth/logout, a password reset by an administrator or the deactivation of an account increments this value, which invalidates every existing token of that user instantly, on all of their machines.

Curl

To call the API from a terminal. Note the .token: the response of /api/auth/login is the bare object, there is no .data to traverse.

TOKEN=$(curl -s -X POST https://uversion.mygamestudio.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret"}' | jq -r '.token')

# The authenticated routes, on the other hand, do use the envelope:
curl -s -H "Authorization: Bearer $TOKEN" \
  https://uversion.mygamestudio.com/api/repositories | jq '.data'

Authentication

Reminder: no route in this section uses the {"success", "data"} envelope. They return the bare object, and their errors take the form {"error": "..."}.

POST /api/auth/login

Authenticates a user and returns a session token valid for 30 days.

Body:

{
  "username": "alice",
  "password": "secret"
}

Response 200:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "user": {
    "id": 12,
    "username": "alice",
    "email": "alice@mygamestudio.com",
    "role": "lead"
  },
  "must_change_password": false
}

There is no expires_at field: the validity period is read from the token itself, or inferred from the server configuration (30 days by default).

Do not ignore must_change_password. This field is true when the account is still running on a temporary password: the one the installer generated for the initial administrator account, or the one an administrator just set during a reset. A client that does not look at it leaves the user on that provisional password indefinitely. The expected behavior is to redirect immediately to POST /api/auth/change-password before any other action.

Errors:

  • 401: invalid credentials or deactivated account
  • 429: 5 failed attempts reached for this username within the 15-minute window

The server always runs the Argon2id password verification, including against a dummy hash when the account does not exist. The response therefore takes the same time in both cases, which prevents guessing whether an account exists by timing the requests.

POST /api/auth/refresh

Renews the session token without going through the password again.

Headers: Authorization: Bearer <current_token>. No body.

Response 200: exactly the same shape as /login (token, user, must_change_password), with a fresh token.

Errors:

  • 401: invalid or expired token, deactivated account, or stale token_version

POST /api/auth/logout

Invalidates all tokens of the current user, on every machine, by incrementing users.token_version. They will have to log in again everywhere: desktop client, editor plugin and CLI included.

Response 200: { "logged_out": true }.

POST /api/auth/validate

Checks that a token is still valid and returns the user it corresponds to. The check also covers is_active and token_version, so a revoked token is rejected even if it has not yet expired.

Watch the body: it is not an object, it is a bare JSON string, that is, the token wrapped in double quotes.

curl -X POST https://uversion.mygamestudio.com/api/auth/validate \
  -H "Content-Type: application/json" \
  -d '"eyJhbGciOiJIUzI1NiIs..."'

Response 200: a bare user object.

{
  "id": 12,
  "username": "alice",
  "email": "alice@mygamestudio.com",
  "role": "lead"
}

There is no valid, no expires_at, no refreshed_token: the validity is read from the HTTP code (200 or 401), and renewal goes through /api/auth/refresh, never through this route.

POST /api/auth/register

This route refuses by default. Open registration is disabled unless the operator has explicitly enabled it in the server configuration; otherwise the response is 403 with {"error": "Open registration is disabled; contact your administrator"}.

In normal operation, accounts are created through administration: POST /api/admin/users, or the Users tab of the admin panel. Do not build an integration that depends on /register.

POST /api/auth/change-password

Changes the current user's password. Increments token_version, which invalidates all previous tokens, including the one that was just used to make the call.

Repositories

GET /api/repositories

Lists the repositories accessible to the current user, filtered by the permissions table. A user with no permission rule on a repository does not see it. The admin and lead roles see every repository.

Query params: just one, include_inactive (boolean, default false), and it is honored only for a super administrator. There is no limit and no offset: the route returns the whole list.

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 1,
      "name": "hero-rpg",
      "description": "Main RPG project",
      "storage_path": "/var/lib/uversion/data/hero-rpg",
      "is_active": true,
      "created_at": "2026-01-15T10:00:00Z",
      "updated_at": "2026-05-15T08:30:00Z"
    },
    {
      "id": 2,
      "name": "shared-assets",
      "description": "Shared asset library",
      "storage_path": "/var/lib/uversion/data/shared-assets",
      "is_active": true,
      "created_at": "2026-02-01T09:00:00Z",
      "updated_at": "2026-05-02T11:12:00Z"
    }
  ]
}

These are the only fields returned. In particular, there is no owner, no current_revision, no file_count, no size_bytes, no last_commit_at: a repository has no owner in the API sense, and volume figures are obtained through the admin statistics routes.

GET /api/repositories/{repo_id}

Details of a repository. Same object shape as in the list, with the same fields.

Errors:

  • 403: no access to this repository
  • 404: repository does not exist

POST /api/repositories

Creates a repository. Super-administrator only, that is, the admin role and it alone. This is not a capability: the check is directly on the role, so a project_admin or a lead gets a 403. There is no create_repos capability in the product.

Body:

{
  "name": "new-project",
  "description": "Optional description"
}

Validation:

  • name: 1 to 255 characters. The server applies no character-set constraint. The on-disk storage path is derived from the name by normalizing it.
  • description: optional.

Uniqueness is on the name alone, server-wide. There is no notion of owner, hence no "per-owner" uniqueness.

Errors:

  • 400: empty name or beyond 255 characters
  • 403: the caller does not have the admin role
  • 409: a repository already has this name

Files

POST /api/files/{repo_id}/upload-chunks

Sends an entire file, not a list of pieces. The route name is misleading: it is the server that splits the file into blocks (the chunks), not you. A caller never has to do that splitting itself.

Identical blocks, recognized by their SHA-256 hash, are deduplicated: a block already present on the server is not stored a second time, whatever the file or the repository it comes from. That is what makes a large binary file changed at the margin cost almost nothing in disk space.

Body: a single-field object, containing the complete file encoded in base64.

{
  "data": "<the entire file, base64-encoded>"
}

Any other shape, in particular a chunks array, is rejected by the server.

Response 200:

{
  "success": true,
  "data": {
    "chunks": [
      { "hash": "abc123...", "offset": 0,       "size": 1048576, "compressed_size": 423152 },
      { "hash": "def456...", "offset": 1048576, "size": 2097152, "compressed_size": 891204 }
    ],
    "chunks_stored": 1,
    "chunks_deduplicated": 1
  }
}

The chunks array is to be reused as-is, complete objects included, in the POST /commit that follows. chunks_stored counts the blocks actually written to disk and chunks_deduplicated those that already existed.

Permission: write access to the repository is required, otherwise 403.

Limits: request body capped at 1 GB (configurable via security.max_body_size_files). A file larger than this limit cannot go through this route.

POST /api/files/{repo_id}/commit

Creates an atomic commit from a list of files and their blocks. Either all files go through, or none.

The only three accepted values for action are add, modify and delete

Not added, not modified, not deleted.

This is not a formatting detail. The server literally tests action == "delete" and treats everything else as an add or a modify. Sending "deleted" therefore deletes nothing at all: the file goes into the write branch, with an empty chunks array, and the server records a revision with empty content. No error is raised. The file remains present, its latest version is overwritten with emptiness, and the loss only shows up at someone else's next sync.

Body:

{
  "message": "Updated main level + hero pose pass",
  "commit_hash": "optional: to group several batches under a single commit",
  "files": [
    {
      "path": "Content/Maps/MainLevel.umap",
      "action": "modify",
      "chunks": [
        { "hash": "abc123...", "offset": 0,       "size": 1048576, "compressed_size": 423152 },
        { "hash": "def456...", "offset": 1048576, "size": 2097152, "compressed_size": 891204 }
      ]
    },
    {
      "path": "Content/Characters/NewVillain.uasset",
      "action": "add",
      "chunks": [
        { "hash": "fed789...", "offset": 0, "size": 524288, "compressed_size": 201004 }
      ]
    },
    {
      "path": "Content/OldAsset.uasset",
      "action": "delete",
      "chunks": []
    }
  ]
}

chunks is not optional, including on a delete. The field must be present: omitting it fails deserialization of the entire request. For a deletion, send an empty array.

The array holds objects, not strings: reuse unchanged the {hash, offset, size, compressed_size} entries returned by /upload-chunks. Each hash must be a 64-character hexadecimal digest, otherwise the entire commit is refused with 400.

The root-level commit_hash field is optional. It is used to have several successive calls carried by one and the same commit, which the desktop client does when it splits a large upload into batches. If omitted, the server computes one.

Response 200:

{
  "success": true,
  "data": {
    "commit_hash": "7f3a9b1c2d3e4f...",
    "files_committed": 3,
    "revisions": [
      { "path": "Content/Maps/MainLevel.umap",             "revision_number": 12 },
      { "path": "Content/Characters/NewVillain.uasset",    "revision_number": 1  },
      { "path": "Content/OldAsset.uasset",                 "revision_number": 8  }
    ]
  }
}

These are the only fields returned. There is no files_changed, no bytes_uploaded, no bytes_deduped, no revision. Note that revision_number is a per-file counter, not a repository version number: it is commit_hash that identifies the commit, and that is what you must keep to reference a state.

Errors:

  • 400: malformed body, invalid block digest (expected: 64 hexadecimal characters), missing chunks field
  • 403: no write permission on one of the paths
  • 409: lock held by someone else on a modified file, or concurrent commit on the same file

GET /api/files/{repo_id}/snapshot

Returns the state of the repository as it was at a given commit: the list of files present, with their revision number and size. Used by the desktop client for cloning and for forced synchronization.

Query params:

  • commit_hash: required. It is the hash of the commit that serves as reference point. Without this parameter the request is rejected, and there is no "latest state" default value.

There is no revision parameter. A snapshot is requested by commit hash, never by number. An unknown commit responds 404.

Response 200: a flat array, with no wrapping object.

{
  "success": true,
  "data": [
    {
      "path": "Content/Maps/MainLevel.umap",
      "revision_number": 12,
      "file_size": 84934656
    },
    {
      "path": "Content/Characters/Hero.uasset",
      "revision_number": 3,
      "file_size": 5242880
    }
  ]
}

The response does not contain the block lists. To retrieve content, go through GET /api/files/{repo_id}/content, which reassembles the file server-side.

GET /api/files/{repo_id}/content

Downloads the content of a file at a given revision (the server reassembles the chunks). Used by clone and sync.

Query params:

  • path: file path (relative to the repo root)
  • revision (optional): revision number. Default: latest.

Response 200: the binary content of the file.

GET /api/files/{repo_id}/history

Commit history of the repository, from most recent to oldest.

Query params:

  • limit: number of commits, default 50
  • offset: default 0
  • path (optional): keeps only the commits that touched this file

These are the only three parameters read. There is no author and no since: an unknown parameter is silently ignored, which gives a response that is plausible but unfiltered. Filter by author or by date on the caller side.

Response 200: a flat array of commits.

{
  "success": true,
  "data": [
    {
      "commit_hash": "7f3a9b1c...",
      "message": "Fixed lighting in main level",
      "author": "alice",
      "created_at": "2026-05-15T08:30:00Z",
      "files": [
        {
          "path": "Content/Maps/MainLevel.umap",
          "revision_number": 12,
          "file_size": 84934656
        }
      ]
    }
  ]
}

There is no total, no has_more, no echo of limit and offset. To walk the whole history, increment offset until you receive fewer items than limit.

Each commit directly carries the list of files it touched. An entry whose revision is a deletion is marked as such and has no content to download.

Incremental sync

Endpoints used by the desktop client to synchronize efficiently:

  • GET /api/files/{repo_id}/sync: files changed since a revision, for a delta sync.
  • GET /api/files/{repo_id}/deletions: files deleted server-side, to propagate deletions locally.
  • GET /api/files/{repo_id}/list: list of the repo's files.
  • POST /api/files/{repo_id}/checkout: acquires the locks and prepares editing.

Locks

Locks never expire

A lock is held until it is explicitly released: by a checkin, by a revert, or by an administrator's force-unlock. There is no automatic expiry, not after an hour, not after a month.

The expires_at field exists only because the corresponding database column does not accept an empty value. The server writes a hundred-year sentinel value into it: a lock taken today shows a deadline somewhere around 2126. Do not build anything on this field, and do not show this date to a user.

The heartbeat therefore extends nothing. It is a monitoring signal, whose only purpose is to show administrators which locks are still actively used.

POST /api/locks/{repo_id}/acquire

Acquires locks on a list of paths. The acquisitions are independent: the acquired list holds the successes, the failed list the failures with their reason.

Body:

{
  "paths": [
    "Content/Maps/MainLevel.umap",
    "Content/Characters/Hero.uasset"
  ]
}

Response 200:

{
  "success": true,
  "data": {
    "acquired": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "file_id": 12345,
        "file_path": "Content/Maps/MainLevel.umap",
        "user_id": 12,
        "username": "alice",
        "acquired_at": "2026-05-15T14:30:00Z",
        "expires_at": "2126-05-15T14:30:00Z"
      }
    ],
    "failed": [
      {
        "path": "Content/Characters/Hero.uasset",
        "reason": "File is locked",
        "locked_by": "bob"
      }
    ]
  }
}

The 2126 deadline in this example is not a typo: it is the sentinel value described above. The lock is permanent.

Failure reasons. The reason field is an English sentence meant to be displayed, not a stable code. Do not write logic that compares this string, and do not "parse" its prefix: it may be reworded from one version to the next. The values currently produced are:

reasonMeaninglocked_by
File is lockedSomeone else already holds the lockThe person's name
No write permissionNo write access on this pathnull
Failed to create lockCreating the lock failed in the databasenull
Database errorDatabase error on this pathnull

An invalid path (absolute, containing .., empty, beyond 4096 characters or carrying a null byte) does not produce an entry in failed: it fails the entire request.

POST /api/locks/{repo_id}/release

Releases locks that you hold. Body:

{
  "paths": ["Content/Maps/MainLevel.umap"]
}

There is no force field on this route. Adding one has no effect: the server ignores fields it does not know, the request succeeds, and someone else's lock stays in place. An integrator who relies on it believes the file was released when it was not.

To remove someone else's lock, the only route is POST /api/admin/locks/{lock_id}/force-release. It is reserved to the administration of the repository concerned, and the operation is written to the audit log. It takes the lock identifier, which you obtain via GET /api/locks/{repo_id}/status.

POST /api/locks/{repo_id}/heartbeat

Updates the activity timestamp of your locks. This extends nothing, since nothing expires: it is a monitoring signal, which lets an administrator tell a lock still in use from a forgotten one.

Body: a JSON array of paths, directly, with no wrapping object.

["Content/Maps/MainLevel.umap", "Content/Characters/Hero.uasset"]

Invalid paths are ignored individually rather than failing the whole batch, so that a stale tracking leftover does not block the others.

GET /api/locks/{repo_id}/status

Lists all the locks in the repository, with the file name and the name of the person holding it.

Query params: none. There is no user filter, no limit, no offset. The route returns every lock in the repository, to be filtered on the caller side.

Response 200: a flat array, with no wrapping object and no total.

{
  "success": true,
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "file_id": 12345,
      "file_path": "Content/Maps/MainLevel.umap",
      "user_id": 12,
      "username": "alice",
      "acquired_at": "2026-05-15T14:30:00Z",
      "expires_at": "2126-05-15T14:30:00Z"
    }
  ]
}

The id field is the one to pass to POST /api/admin/locks/{lock_id}/force-release.

Comments & Reviews

GET /api/comments/{repo_id}/comments?commit_hash=<hash>

Lists the comments of a commit, threaded (parent → children).

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 42,
      "commit_hash": "7f3a9b1c...",
      "file_path": "Content/Maps/MainLevel.umap",
      "author_id": 12,
      "author_username": "alice",
      "body": "LGTM, ship it",
      "parent_id": null,
      "created_at": "2026-05-15T15:00:00Z",
      "replies": [
        {
          "id": 43,
          "author_username": "bob",
          "body": "Thanks!",
          "parent_id": 42,
          "created_at": "2026-05-15T15:05:00Z"
        }
      ]
    }
  ]
}

POST /api/comments/{repo_id}/comments

Creates a comment. file_path is optional (commit comment vs. file comment). parent_id to reply to an existing thread.

Body:

{
  "commit_hash": "7f3a9b1c...",
  "file_path": "Content/Maps/MainLevel.umap",
  "body": "LGTM, ship it",
  "parent_id": null
}

POST /api/comments/{repo_id}/reviews

Submits a review on a commit. approve_changes capability required.

Body:

{
  "commit_hash": "7f3a9b1c...",
  "status": "approved",
  "comment": "Lighting looks great, approving"
}

Accepted status: approved, changes_requested, pending.

Watchlist

GET /api/watchlist/{repo_id}/watchlist

Lists the current user's watch patterns on this repository.

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 1,
      "user_id": 12,
      "pattern": "Content/Characters/Hero/**",
      "notify_on": ["commit", "lock"],
      "created_at": "2026-04-10T10:00:00Z"
    }
  ]
}

POST /api/watchlist/{repo_id}/watchlist

Adds a watch pattern. Body:

{
  "pattern": "Content/Characters/Hero/**",
  "notify_on": ["commit", "lock"]
}

The possible events: commit (a commit modified a matching file), lock (a lock was acquired), review (a review was posted).

DELETE /api/watchlist/{repo_id}/watchlist/{watch_id}

Removes a watch pattern. Returns { "success": true }.

Production board (tasks)

The old modification-requests API was removed: the production board absorbs it (requests become cards, origin='request'). The endpoints are nested under /api/tasks/{repo_id}.

RouteDescription
GET /api/tasks/{repo_id}/boardFull board: columns + cards
POST /api/tasks/{repo_id}/tasksCreates a card
PUT/DELETE /api/tasks/{repo_id}/tasks/{task_id}Updates / deletes a card
PUT /api/tasks/{repo_id}/tasks/{task_id}/assigneesAssigns users to a card
GET /api/tasks/{repo_id}/assignable-usersList of assignable users
POST /api/tasks/{repo_id}/columnsConfigures the board columns

Also available sub-routes: card comments, links to assets and commits, and attachments (with cover image). Configuring the columns requires the manage_board capability (admin / lead).

Builds

POST /api/builds/{repo_id}/upload?hash=<sha256>

Uploads a build file to <storage_path>/builds/<hash>. The server verifies the SHA-256 given in the query param against the content received. If the hash already exists server-side, it returns 200 immediately without copying again (dedup on the build storage side).

Headers: Content-Type: application/octet-stream.

Body: raw binary (no JSON wrapping).

Limits: 8 GB per file.

Errors:

  • 400: hash query param missing or malformed, or computed SHA-256 != hash provided
  • 413: file > 8 GB

POST /api/builds/{repo_id}/publish

Registers the manifest of a build after all its files are uploaded. publish_builds capability required.

Body:

{
  "version": "0.1.5-nightly",
  "config": "Development",
  "platform": "Win64",
  "executable_path": "HeroRPG/Binaries/Win64/HeroRPG.exe",
  "release_notes": "Nightly build of main branch, 2026-05-15",
  "files": [
    { "path": "HeroRPG.exe",                "hash": "abc123...", "size_bytes": 12345678 },
    { "path": "HeroRPG/Content/Paks/pak0.pak", "hash": "def456...", "size_bytes": 2147483648 }
  ]
}

GET /api/builds/{repo_id}

Lists the published builds of this project.

Build access is granted project by project. The download_builds capability is not enough: being server-wide, it only says that the account is not a mere spectator. On top of it you need either access to the repository or an explicit authorization on this project's builds, granted by its administrator via /api/admin/repositories/{repo_id}/build-access. A project administrator accesses those they administer automatically, a super administrator all of them.

Response 200:

{
  "success": true,
  "data": [
    {
      "id": 5,
      "version": "0.1.5-nightly",
      "config": "Development",
      "platform": "Win64",
      "published_by": "ci-nightly",
      "published_at": "2026-05-15T03:00:00Z",
      "size_bytes": 2159829326,
      "file_count": 142,
      "release_notes": "Nightly build of main branch, 2026-05-15"
    }
  ]
}

GET /api/builds/{repo_id}/{build_id}/file

Downloads a file of a build. download_builds capability required. The manifest of a build is available via GET /api/builds/{repo_id}/{build_id}/manifest.

Admin

What actually guards these routes

Contrary to what one might expect, the /api/admin/* routes are not each guarded by a capability. They go through one of four checks, almost all of which are on the role. A capability is a named right attached to a role; the key point is that it is valid across the whole server, because it carries no reference to a repository. It can therefore never be used to confine someone to a project.

CheckPasses forWhat it is for
Super administrator The admin role, and it alone Everything that applies to the whole server: accounts, groups, audit log, global statistics, licence, server update.
Administrator of this repository admin, or a project_admin who administers this specific repository Everything specific to a project: permissions, validation rules, webhooks, build access, force-unlock, garbage collection.
Server-wide capability Any role that holds the named capability A few cross-cutting routes. Beware: the capability applies to all repositories, that is its nature. A project_admin, who holds none, is admitted instead only on the repositories they administer.
Admission only admin or project_admin Lets in, but authorizes nothing. The route that uses it must then restrict its own results to the repositories administered by the caller.

In other words: the manage_users and manage_permissions capabilities exist only on paper. They are indeed created in the database at install time, but no line of code queries them. Granting them to a role changes nothing at all. Do not write an integration that assumes a non-admin account will be able to manage users because it was given manage_users: it will get a 403.

RouteActual checkDescription
GET /api/admin/usersAdmission only, then filteringA project_admin sees only the accounts within their scope
POST /api/admin/usersAdmission onlyCreates an account
PUT/DELETE /api/admin/users/{id}Super administratorUpdates or deletes an account
POST /api/admin/users/{id}/reset-passwordSuper administratorResets the password and revokes all the account's tokens
GET/POST /api/admin/groupsSuper administratorGroups are global, they cannot be delegated per project
GET/POST /api/admin/permissions/{repo_id}Administrator of this repositoryPermission rules by path pattern, granted to a group or a user
GET/POST /api/admin/repositories/{repo_id}/rulesAdministrator of this repositoryValidation rules applied before upload
GET/POST /api/admin/repositories/{repo_id}/webhooksAdministrator of this repositoryDiscord, Slack, Teams, or generic webhook
GET /api/admin/locksAdmission only, then filteringLocks, restricted to the administered repositories
POST /api/admin/locks/{lock_id}/force-releaseAdministrator of this repositoryRemoves someone else's lock. Written to the audit log
GET /api/admin/auditSuper administratorAudit log, filterable and paginated
GET /api/admin/statsSuper administratorStorage volume, deduplication ratio, growth
GET /api/admin/licenceSuper administratorLicence status and seats consumed
GET /api/admin/repositories/{repo_id}/gc/previewAdministrator of this repositorySimulates garbage collection without deleting anything
POST /api/admin/repositories/{repo_id}/gcAdministrator of this repositoryRuns garbage collection

The full detail of the roles, their ranks and what each can do is in the page dedicated to roles and permissions.

API areas not covered by this page

The following families of routes exist, are mounted and served by the server, but are not described here. If your integration needs them, the most reliable option today is to observe the calls the desktop client makes, or to write to us.

PrefixWhat it covers
/api/advisor/*Project Health: the Unreal project audit, its findings, the per-pillar score and the triage of items to ignore.
/api/distribution/*Cross-project distribution: links between a source repository and a target repository, publishing files from one project to another, history.
/api/binaries/*Precompiled editor binaries, tied to a commit, on the UnrealGameSync model.
/api/watchlist/*Beyond the watch patterns described above: notifications and the inbox.
/api/profile/*The current user's profile.
/api/admin/repositories/{repo_id}/adminsWho administers a repository: appointing and removing project administrators.
/api/admin/repositories/{repo_id}/build-accessWho can download this project's builds, granted project by project.
/api/admin/licenceLicence status and seats consumed.
/api/admin/server/*Server update from the admin panel.

Health

GET /health

Unauthenticated endpoint returning 200 OK if the server can reach the database. Used for load-balancer or monitoring health checks (Prometheus blackbox, Datadog synthetic, etc.).

Response 200: OK (text/plain).

Response 503: if the database is unreachable.

GET /api/server-info

Public unauthenticated endpoint returning the server metadata (version, etc.). Like /health, it is not protected by the auth middleware.

Error format

On error, an authenticated route responds { "success": false, "error": "..." }, and an /api/auth/* route responds { "error": "..." }, in both cases with an appropriate HTTP code.

The error field is a message meant for a human, not a code. There is no catalogue of stable error codes, neither in the body nor in a header. So do not build logic on its content, and do not parse its prefix: these sentences are reworded from one version to the next, and a string test that breaks silently is worse than no test at all.

The HTTP code is the only thing on which to branch behavior. The table below gives its reading.

HTTPMeaningWhen
400Bad requestInvalid parameter, malformed JSON, body too large (before the hard 413 limit)
401Not authenticatedToken missing, expired, invalid signature, or token_version mismatch
403Permission deniedMissing capability, or no permission on the path / repo
404Resource not foundRepo / file / commit / user nonexistent or inaccessible
409ConflictLock already held, concurrent commit, unique constraint violated
413Payload too largeBody beyond the limit (1 GB on /files, 8 GB on /builds/upload)
429Too many requestsOnly on /auth/login (per-user rate limiting)
500Internal server errorDB error, IO error. Logged server-side, attach the timestamp if you report the bug.
503Service unavailableDatabase unreachable (health check) or migration in progress