This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
HTTP API
Everything a client can reach, as it behaves after M3. web/src/lib/api.ts and
internal/client are both written against this document; where the two ever
disagree with each other, this is the arbiter.
Conventions
Base path /api/v1. JSON in, JSON out, UTF-8. Request bodies are capped at
64 KB and unknown fields are rejected — a misspelled key is a 400, not a
silently ignored field.
Every failure has the same body:
{"error": "a sentence a user can act on"}
The status vocabulary the whole surface uses:
| Code | Means |
|---|---|
200 |
Read succeeded |
202 |
The build was accepted and queued; it has not run yet |
204 |
Done, no body — cancel only |
302 |
Redirect to a presigned object URL |
400 |
A well-formed request naming something that does not exist, or an invalid spec |
404 |
A resource addressed by ID that is not there |
409 |
The resource is in a state that does not allow this |
500 |
A fault on the server; the detail is in its log, not in the response |
503 |
This instance is not configured with a dependency the route needs |
400 versus 404 is a deliberate split, not an accident. An unknown
godot_ref is a 400: the client asked for a version this instance does not
offer, which is a bad request, not a missing page. An unknown job ID is a
404, because that is an addressed resource.
503 means this particular server was started without something — no queue, no
job store, no catalog, no artifact store. It is never a transient condition
except in one case: a full build queue, which is also 503.
Health
GET /healthz
200 with {"status":"ok"}. No authentication, no dependencies checked — it
answers as long as the process is serving. Startup is where an unreachable
database, daemon, or bucket is caught; the process exits rather than coming up
degraded.
Builds
POST /api/v1/builds
Body: a BuildSpec.
| Code | When |
|---|---|
202 |
Queued. The body is the Job, in status queued |
400 |
The spec is invalid, names an unknown field, names a module this engine version does not offer, or names an unknown godot_ref |
503 |
No queue is configured; or a spec naming modules on a server with no catalog; or the queue is full |
500 |
The job could not be recorded |
The response is the job object itself, not an envelope.
The module selection is checked against the chosen engine version's list before anything is queued, so a spec naming a module Godot 4.3 does not offer fails here — naming the module — rather than in a worker an hour later. A server with no catalog cannot make that check, so it refuses a spec with modules rather than queueing one nothing has looked at; a stock-engine build still works.
A full queue is 503 because the alternative is worse: a persisted job in
queued that no worker will ever take. The submission fails while the client is
still listening instead.
GET /api/v1/builds
| Parameter | Meaning |
|---|---|
status |
Keep only these statuses. Repeatable (?status=a&status=b) and comma-separated within one value (?status=a,b). An unrecognised status is a 400 naming the allowed set |
limit |
A positive integer, capped at 200. Absent uses the server's default of 50 |
before |
Return only jobs older than this job ID. Job IDs are ULIDs, so this is a time cursor |
{"builds": [ /* Job, newest first */ ]}
builds is always an array. An empty listing is [], never null. 503 with
no job store.
GET /api/v1/builds/{id}
200 with the Job, or 404. 503 with no job store.
POST /api/v1/builds/{id}/cancel
| Code | When |
|---|---|
204 |
Accepted. No body |
404 |
No such job |
409 |
The job is already finished, or it is packaging |
503 |
No queue is configured |
204 carries no body deliberately: the browser client parses JSON for every
status but 204, so an empty 200 would throw.
Cancelling during packaging is a 409, not a courtesy 204. The binary
is built and uploading at that point; packaging → canceled is not a legal
transition and is not going to become one.
A 204 means the cancel was accepted, not that the job is already
canceled — a running build's worker writes the terminal status once its
container is gone. Poll GET /api/v1/builds/{id} if you need to see it land.
Log streaming (SSE)
GET /api/v1/builds/{id}/logs
In anonymous mode this route serves sanitized public progress
without authentication, with per-IP stream limits and Last-Event-ID replay.
Raw logs are administrative at GET /api/v1/admin/builds/{id}/logs. The legacy
token-mode contract below remains unchanged.
text/event-stream. Also Cache-Control: no-cache, Connection: keep-alive,
and X-Accel-Buffering: no — the last so an nginx-shaped proxy does not hold
the stream in a buffer and deliver the whole log when the build ends. The
headers are flushed immediately, so EventSource fires open before the build
next says anything.
404 (JSON, not a stream) for an unknown job. 503 with no queue.
The format, in full:
- Every log line is one default (unnamed) event with exactly one
data:field:data: <line>\n\n. - For a running build, the retained tail is replayed first, in order, then live lines follow. The tail and the live subscription are taken together under one lock, so no line is lost or repeated at the boundary.
- For a finished build, the complete log is replayed from its file and the stream ends. This is the common case after a server restart.
- For a queued build the stream stays open, sending only heartbeats, until a worker picks the job up — then it continues as (2). A queued build has no log to send yet, and ending its stream would tell a client following its own submission that there is nothing to follow. The stream ends immediately only for a job that is already in a terminal state and wrote nothing, which is a build canceled before it ever ran.
- A line starting with
:is a heartbeat and carries no content. One is sent after 20 seconds of silence. Ignore it. - The stream ends with a named event:
event: endfollowed by an emptydata:field. There is nothing after it. - There is no
id:field and no resume support. A reconnecting client gets the tail again.
The end event has to be named. EventSource delivers every default event to
onmessage, and today's UI renders whatever arrives there as a log line — a
terminal marker sent as a default event would be printed to the user as though
the build had emitted it.
A client that cannot keep up drops lines rather than stalling the build: the server's fan-out to subscribers never blocks. The complete record is always in the job's log file, which is what a finished job's stream replays.
data: scons: Building targets ...
: keepalive
data: [ 12%] Compiling core/os/os.cpp
event: end
data:
Objects
GET /api/v1/builds/{id}/artifacts/{name}
302 to a presigned URL by default, so the object store serves the bytes. A
deployment whose clients cannot reach the object store, and the filesystem
backend, stream through the server instead. 404 for a missing artifact, 503
with no artifact store. The rules and the reasoning are in
artifacts.md.
Verify the download against sha256 on the job's ArtifactRef. The pipeline
records its own digest during collection rather than trusting the object
store's, because S3 reports a checksum-of-checksums with a -N suffix for
multipart uploads, and every build artifact is large enough to go multipart.
Modules and versions
GET /api/v1/modules?godot_ref=<ref>
godot_ref is required. The bare form is a 400, deliberately: what is on
offer is a property of the engine version, so "what modules are there" is not a
question this system has an answer to. Inventing one would mean showing a user
modules their build will reject.
{
"godot": {"ref": "4.4-stable", "major": 4, "minor": 4},
"modules": [
{"name": "b", "version": "v1.0.0", "ref": "v1.0.0",
"path": "modules/b", "description": "...", "requires": ["a"]}
]
}
400 for an unknown ref, 503 with no catalog.
GET /api/v1/godot-versions
{"versions": [{"name": "4.4 stable", "ref": "4.4-stable",
"major": 4, "minor": 4, "default": true},
{"name": "4.3 stable", "ref": "4.3-stable",
"repo": "https://github.com/studioteal/godot.git",
"major": 4, "minor": 3}]}
repo and default are omitted when empty and false, as above: a version with
no repo of its own comes from the configured engine repository, and only one
version is the default.
major and minor are carried explicitly rather than parsed from ref:
master, a commit SHA, and a fork branch have no parseable version, and the
compatibility list is keyed on the pair.
Objects in the responses
The BuildSpec object
| Field | Type | Notes |
|---|---|---|
godot_ref |
string, required | A tag, branch, or commit SHA in the engine repository |
platform |
string, required | linuxbsd, windows, macos |
arch |
string, required | x86_64, arm64 — but not every pair is buildable; see below |
target |
string, required | editor, template_debug, template_release |
precision |
string | single (default) or double |
modules |
array | [{"name": "b", "ref": "v1.1.0"}]. ref overrides which commit is cloned; it never changes whether the module is offered |
disable_default_modules |
bool | Compiles only the selected modules |
extra_scons |
array of string | Extra key=value arguments to SCons |
Buildable platform and arch pairs
platform and arch are validated together as well as separately. A pair with
no toolchain image behind it is a 400 before anything is cloned, rather than a
container that fails an hour later:
| Platform | Arches |
|---|---|
linuxbsd |
x86_64, arm64 |
windows |
x86_64 |
macos |
x86_64, arm64 |
Windows on arm64 is refused for want of a cross-compiler, not for want of engine support — Godot builds it upstream with llvm-mingw, which Debian does not package. If that toolchain is ever added, the table widens and nothing else changes.
Arguments the pipeline owns
extra_scons rejects any argument that would desync the spec from the artifact:
platform, arch, target, precision, custom_modules, and
modules_enabled_by_default, plus the settings each toolchain image derives for
itself — osxcross_sdk, vulkan_sdk_path, mingw_prefix, generate_bundle,
and bundle_sign_identity.
The Job object
| Field | Type | Notes |
|---|---|---|
id |
string | A ULID: unique, and lexicographically sortable by creation time, which is what makes before a time cursor |
spec |
BuildSpec | What was asked for. Never rewritten |
cache_key |
string | A digest of the spec |
status |
string | See below |
artifacts |
array of ArtifactRef | Present once the build succeeds |
resolved_modules |
array of ModuleResolution | Written before the compile starts, so a build that fails still says what went into it |
error |
string | The failure reason, on failed; a canceled job carries a short note |
created_at |
RFC 3339 | |
started_at |
RFC 3339 | Set when the job leaves queued |
finished_at |
RFC 3339 | Set on any terminal status |
Statuses and the transitions between them:
queued ──▶ preparing ──▶ building ──▶ packaging ──▶ succeeded
│ │ │ │
├───────────┴─────────────┴────────────┴──────▶ failed
└───────────┴─────────────┴───────────────────▶ canceled
succeeded, failed, and canceled are terminal. Note the one asymmetry:
packaging → failed is legal, packaging → canceled is not — which is why a
cancel during packaging is a 409.
preparing is cloning the engine and resolving modules; building is SCons;
packaging is collecting and storing what came out.
A job left in preparing, building, or packaging by a server that died is
failed on the next startup, with the error interrupted by a server restart.
Jobs still in queued are requeued.
ArtifactRef
| Field | Type | Notes |
|---|---|---|
job_id |
string | The download URL is built from this and name |
name |
string | The download filename, e.g. godot.linuxbsd.editor.x86_64 |
size |
number | Bytes |
sha256 |
string | Computed by the pipeline while collecting. Compare a download against this |
created_at |
RFC 3339 |
ModuleResolution
| Field | Type | Notes |
|---|---|---|
name |
string | |
version |
string | Which release. The question a bug report starts with, and a commit SHA answers it only after a lookup |
endpoint |
string | The repository it came from |
ref |
string | The ref actually used |
commit |
string | The SHA that ref resolved to, so the build is reproducible even when the ref moves |
dest_path |
string | Where it was grafted, e.g. modules/b |
Deliberately flat, and with no mirror path: a persisted record must not carry a path that only meant something on one worker's filesystem.
Modules pulled in transitively through another module's requires appear here
too. A build that compiled something in should say so.
Admin
Nineteen routes under /api/v1/admin/, all behind a bearer token with
is_admin. They are documented in admin.md rather than duplicated
here.
(This said fourteen from M2 until M9. It was already wrong by one before M9 added four reads: M6-10's audit route was never counted.)
One shape differs between the admin and public views of the same row, and it is
deliberate. GET /api/v1/admin/godot-versions carries an id and spells the
default flag is_default; the public GET /api/v1/godot-versions carries no
id and spells it default. The admin form matches the write routes, so a row
read there posts straight back; the public one withholds a database key that
nothing unauthenticated has any use for.
Every one of them answers identically to a missing token, a malformed header, a non-admin token, and a revoked token — that uniformity is the point of the shared middleware, and telling those four cases apart is exactly what an attacker would want.
Authentication
Every build route requires a token. All six of them:
POST /api/v1/builds
GET /api/v1/builds
GET /api/v1/builds/{id}
POST /api/v1/builds/{id}/cancel
GET /api/v1/builds/{id}/logs
GET /api/v1/builds/{id}/artifacts/{name}
The credential is the same hashed bearer token the admin surface has used since
M2 — Authorization: Bearer gbb_…, issued by POST /api/v1/admin/tokens. A
build route takes any valid token; is_admin is required only by the admin
routes. See admin.md for issuing one.
Every rejection is byte-identical, on the build routes and the admin routes
alike: a missing header, a malformed one, the wrong scheme, an empty bearer, an
unknown token, a revoked token, and — on an admin route — a valid non-admin
token all produce the same 401 and the same body. Telling those apart is
exactly what an attacker would want, and the identical answer is an M2
acceptance criterion that M6 extended to a second surface rather than
reimplemented.
What stays open
GET /healthz
GET /api/v1/modules?godot_ref=<ref>
GET /api/v1/godot-versions
plus the frontend itself. Each for a reason. A Kubernetes probe has no
credential to offer, and a liveness check that can answer 401 restarts the pod
it is meant to be watching. What a Godot version offers is not a secret, and the
build form renders itself from those two reads — closing them would mean an
empty page before a token is entered, in exchange for nothing.
The session cookie
A browser cannot send an Authorization header on the two routes it needs most.
EventSource, which is how the frontend follows a build's log, takes a URL and
withCredentials and has no header argument; an artifact download is an
<a href> navigation, which carries no header a script has set.
So POST /api/v1/session exchanges a bearer token for a cookie, and the cookie
authenticates the same routes. GET /api/v1/session reports who you are;
DELETE /api/v1/session clears it. authenticate reads the header first and the
cookie second, so a request carrying both is resolved unambiguously and the CLI
is unaffected.
The cookie is HttpOnly, SameSite=Strict, Secure, and scoped to Path=/api/
— nothing outside /api/ needs it, and the scope keeps the credential off every
static asset request a page load makes.
Its value is the bearer token itself, not a separate session id. That is a
deliberate trade. In favour: revoking the token ends every session it issued,
immediately, with no session table, no expiry to keep in step, and one code path
in the server. Against: the browser's cookie jar holds a working API token, which
lives until it is revoked where a session id would have died on its own.
HttpOnly keeps script out of it and the path scope narrows where it is sent.
The alternative buys independent expiry and little else, at the cost of a table,
a migration, and a second lifetime to get wrong.
There is no CSRF token, and SameSite=Strict is why. The cookie is sent on
no cross-site request at all — not a form POST, not an <img> GET, not a
top-level navigation — and the SPA is served from the same origin as the API, so
no legitimate request to these routes originates elsewhere. That combination is
the whole defence. If the frontend is ever served from a second origin, this
paragraph is what has just been invalidated. As belt and braces, a state-changing
request whose Origin header is present and not same-origin is refused;
present-and-mismatched only, because a CLI sends no Origin.
Strict has one visible cost, and it is not a bug: a deep link to a build, clicked
from a chat client, arrives with no cookie. The SPA loads — it is static and open
— and its first API call is a 401, at which point the frontend shows the token
gate. "Followed a deep link" and "signed out" are the same case, and it handles
both.
Rate limits
Two limits, and they answer the same way: 429 Too Many Requests with a
Retry-After in seconds and the body {"error":"too many requests"}.
- Requests per token, applied once a request has authenticated, because that is the first point at which there is an identity. Behind a Service, an ingress or a NAT an address is shared or constant, so an IP bucket would punish a crowd for one member and let one token spread itself across a proxy pool.
- Requests per IP, for the routes with no identity yet — the two catalog
reads and, the one that matters,
POST /api/v1/session, which says whether a token was right and so must not be free to guess at. - Concurrent log streams per token. A per-second bucket does nothing useful
for SSE: one request opens a stream that lives for hours, holding a goroutine
and a subscriber slot. The request that opens a stream is counted; the minutes
after it are not, because anything that kept counting would kill the stream it
was measuring. This is the limit that binds there, and exceeding it is a JSON
429rather than an event stream whose first line says429.
GET /healthz is never limited: a liveness probe that can be refused restarts
the pod it is meant to be watching. Nor are the frontend's static assets.
These limits are per replica and held in memory. See deployment.md; the durable per-caller budget is the build quota below, which lives in the database.
Build quotas
A token may carry an allowance, counted in builds per rolling window
(api_tokens.build_quota and quota_window; see admin.md). A token
at its allowance gets 429 on POST /api/v1/builds, with a Retry-After
counting until its oldest counted build leaves the window, and no job is
created — no id, no row, no log file.
Every build the token started counts, including failed and canceled ones: a build that failed ninety minutes in consumed ninety minutes of a machine, and excluding it would make this a quota on successes and reward submitting specs that break early.
A token with no allowance is unlimited, which is what every token issued before this existed is. Zero is a different and real state: may download, may not build.
What authenticating a download does and does not cover
The download route requires a token. It then answers 302 to a presigned object
store URL, and that link is a bearer capability of its own for
artifacts.download_ttl — six hours by default. Authenticating the route does
not authenticate the link; anyone holding it can fetch the bytes until it
expires. That is unchanged from M3 and is the trade the presigned redirect exists
to make: an artifact is 50–150 MB and the object store can serve it without
holding a connection open on the API server. The lever is the TTL.
Clients must not send their credential to the redirect target. internal/client
strips Authorization on every redirect rather than relying on the standard
library to do it: Go's rule is isDomainOrSubdomain, so an API served at an apex
domain redirecting to a store. subdomain of it would forward the token.
Guide
- Home
- Getting Started
- Building Godot
- Configuration
- Modules and Administration
- API
- Architecture
- Deployment and Operations
- Security
- Troubleshooting
- Development
Reference
- API
- Architecture
- Modules
- Module registry
- Admin
- Artifacts
- Deployment
- Kubernetes execution
- Durable execution store
- Immutable build configuration
- Download gateway
- Public logs
- Anonymous verification
- Abuse enforcement
- Public service remediation
- Authelia administration
- CI/CD
- macOS SDK