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
| Header | Value |
|---|---|
Authorization | Bearer <jwt> on every /api/* route except /api/auth/* and /api/server-info (and /health) |
Content-Type | application/json for POST/PUT with a JSON body. application/octet-stream for binary uploads (build files). |
Accept | application/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.
| Route | Accepted parameters | Response 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 account429: 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 staletoken_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 repository404: 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 characters403: the caller does not have theadminrole409: 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.
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), missingchunksfield403: no write permission on one of the paths409: 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 50offset: default 0path(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
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:
reason | Meaning | locked_by |
|---|---|---|
File is locked | Someone else already holds the lock | The person's name |
No write permission | No write access on this path | null |
Failed to create lock | Creating the lock failed in the database | null |
Database error | Database error on this path | null |
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}.
| Route | Description |
|---|---|
GET /api/tasks/{repo_id}/board | Full board: columns + cards |
POST /api/tasks/{repo_id}/tasks | Creates a card |
PUT/DELETE /api/tasks/{repo_id}/tasks/{task_id} | Updates / deletes a card |
PUT /api/tasks/{repo_id}/tasks/{task_id}/assignees | Assigns users to a card |
GET /api/tasks/{repo_id}/assignable-users | List of assignable users |
POST /api/tasks/{repo_id}/columns | Configures 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 provided413: 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.
| Check | Passes for | What 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.
| Route | Actual check | Description |
|---|---|---|
GET /api/admin/users | Admission only, then filtering | A project_admin sees only the accounts within their scope |
POST /api/admin/users | Admission only | Creates an account |
PUT/DELETE /api/admin/users/{id} | Super administrator | Updates or deletes an account |
POST /api/admin/users/{id}/reset-password | Super administrator | Resets the password and revokes all the account's tokens |
GET/POST /api/admin/groups | Super administrator | Groups are global, they cannot be delegated per project |
GET/POST /api/admin/permissions/{repo_id} | Administrator of this repository | Permission rules by path pattern, granted to a group or a user |
GET/POST /api/admin/repositories/{repo_id}/rules | Administrator of this repository | Validation rules applied before upload |
GET/POST /api/admin/repositories/{repo_id}/webhooks | Administrator of this repository | Discord, Slack, Teams, or generic webhook |
GET /api/admin/locks | Admission only, then filtering | Locks, restricted to the administered repositories |
POST /api/admin/locks/{lock_id}/force-release | Administrator of this repository | Removes someone else's lock. Written to the audit log |
GET /api/admin/audit | Super administrator | Audit log, filterable and paginated |
GET /api/admin/stats | Super administrator | Storage volume, deduplication ratio, growth |
GET /api/admin/licence | Super administrator | Licence status and seats consumed |
GET /api/admin/repositories/{repo_id}/gc/preview | Administrator of this repository | Simulates garbage collection without deleting anything |
POST /api/admin/repositories/{repo_id}/gc | Administrator of this repository | Runs 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.
| Prefix | What 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}/admins | Who administers a repository: appointing and removing project administrators. |
/api/admin/repositories/{repo_id}/build-access | Who can download this project's builds, granted project by project. |
/api/admin/licence | Licence 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.
| HTTP | Meaning | When |
|---|---|---|
| 400 | Bad request | Invalid parameter, malformed JSON, body too large (before the hard 413 limit) |
| 401 | Not authenticated | Token missing, expired, invalid signature, or token_version mismatch |
| 403 | Permission denied | Missing capability, or no permission on the path / repo |
| 404 | Resource not found | Repo / file / commit / user nonexistent or inaccessible |
| 409 | Conflict | Lock already held, concurrent commit, unique constraint violated |
| 413 | Payload too large | Body beyond the limit (1 GB on /files, 8 GB on /builds/upload) |
| 429 | Too many requests | Only on /auth/login (per-user rate limiting) |
| 500 | Internal server error | DB error, IO error. Logged server-side, attach the timestamp if you report the bug. |
| 503 | Service unavailable | Database unreachable (health check) or migration in progress |