Files
Browse, read and write a game server's files, archive them, and move bytes in and out.
Everything a script usually wants to do to a game server touches files — deploying
a mod pack from CI, rewriting server.properties before a restart, pulling a world
out for a backup, dropping a config in.
The per-endpoint schemas and the playground are in the API Reference. This page is the part the schemas cannot say.
Files have their own two abilities
| Ability | Reaches |
|---|---|
game-servers.files.read | browsing, reading, and download URLs |
game-servers.files.manage | everything that writes, and upload URLs |
They are not implied by game-servers.read and game-servers.manage, and they
do not imply each other.
A key that can restart your servers cannot read their files, and that is deliberate. A server's config files hold its RCON password and its database credentials — a key you hand to a deploy pipeline should not be a key that can read those out.
Like the rest of the game-server area, neither needs an activation flag.
Paths
Every path is absolute from the server's own root — the directory the game
runs in, not the node's filesystem. /server.properties is the file beside your
jar, and there is nothing above / to reach.
A .. segment is refused with a 422 rather than resolved. That is not
squeamishness: whether /plugins/link/../config.yml means /config.yml depends on
whether link is a symlink, and only the node knows. Rather than guess and
disagree with it, this API does not accept the question.
The batch endpoints take a root and paths relative to it, which saves repeating
a long prefix:
{
"root": "/plugins/EssentialsX",
"files": ["config.yml", "worth.yml"]
}Browsing
curl -G https://one.terabit.io/api/v1/game-servers/$SERVER/files \
-H "Authorization: Bearer tbk_..." \
-d directory=/plugins{
"data": [
{
"name": "EssentialsX",
"path": "/plugins/EssentialsX",
"is_file": false,
"is_symlink": false,
"size": 0,
"mode": "drwxr-xr-x",
"mode_bits": "0755",
"mimetype": "inode/directory",
"modified_at": "2026-08-19T14:02:11+00:00"
}
],
"meta": {
"directory": "/plugins",
"current_page": 1,
"per_page": 100,
"total": 1,
"last_page": 1
}
}Directories always sort first; sort and direction order within each group.
search is a case-insensitive substring match on the name, applied to the whole
directory before paging, so a match on page four is still found.
Read path off an entry rather than joining directory and name yourself — it
is already absolute and can be handed straight to any other endpoint here.
Two things the listing does not tell you. size on a directory is 0, not a
recursive total. And mimetype is whatever the node reported, which is often
application/octet-stream for a perfectly ordinary config file — do not use it to
decide whether something is text.
GET files/stat answers the same object for a single path. It is implemented by
listing the parent and picking the entry out, because the node has no per-file
stat, so statting ten files in one directory costs ten listings of it. List once
instead.
Reading a file
GET files/contents answers with the file itself, not JSON. Only its errors
are JSON.
A file is bytes. Encoding one into JSON would refuse anything that is not valid UTF-8, and base64 would inflate every transfer by a third to describe something you already have.
curl -G https://one.terabit.io/api/v1/game-servers/$SERVER/files/contents \
-H "Authorization: Bearer tbk_..." \
-d file=/server.propertiesFiles up to 10 MiB come back whole. Past that you get a 422 naming the size,
and two ways forward:
{
"message": "This file is too large to read inline. …",
"size": 41943040,
"max_inline_bytes": 10485760
}Either ask for a window, or take a download URL and fetch it from the node. The ceiling is about keeping a large read off this API's request path, not about what the node can serve.
Windows
offset and length request a byte range, answered as a 206 with a
Content-Range, so an ordinary HTTP client can walk a large log without any of
this being special-cased:
# the last 64 KiB of a 40 MiB log
curl -G https://one.terabit.io/api/v1/game-servers/$SERVER/files/contents \
-H "Authorization: Bearer tbk_..." \
-d file=/logs/latest.log -d offset=41877504 -d length=65536A window starting past the end of the file is a 416, not an empty 200. Walking
a file that is still being written, you need to be able to tell "you overshot" from
"the file ended here".
Writing a file
The mirror image of the read: the body is the contents, and the path is the
file query parameter, whatever content type you send.
curl -X PUT "https://one.terabit.io/api/v1/game-servers/$SERVER/files/contents?file=/server.properties" \
-H "Authorization: Bearer tbk_..." \
-H "Content-Type: text/plain" \
--data-binary @server.propertiesThere is no append and no patch — the file is replaced entirely, and created if it was not there. Read it, change it, write it back.
The body is held in memory here, so this endpoint is for configs. For anything large or binary, upload it to the node instead.
Moving, copying and deleting
PUT files/rename renames and moves with the same call: to is a path, so a to
in another directory is a move. It takes a batch because moving a directory of
configs one request at a time is a round trip per file, and leaves the move half
applied if one of them fails.
{
"root": "/plugins",
"files": [
{ "from": "EssentialsX.jar", "to": "disabled/EssentialsX.jar" }
]
}POST files/copy copies one entry beside itself. The node picks the new name —
world.zip becomes world copy.zip — and does not report it, so neither can the
response. List the directory afterwards, or move the copy where you want it.
POST files/delete takes a batch. A directory goes with everything beneath it, and
nothing is moved anywhere recoverable.
There is no recycle bin. A backup is the only way back from a delete.
Archives
POST files/compress takes the same root and files batch and creates one
archive beside them. The node names it after the moment it was made, and reports it
nowhere else — so the created entry comes back with the acknowledgement, and that
response is your only chance to learn the name:
{
"ok": true,
"message": "Archive created.",
"archive": {
"name": "archive-2026-08-19T140211Z.tar.gz",
"path": "/archive-2026-08-19T140211Z.tar.gz",
"size": 918273645,
"…": "…"
}
}Archiving a large world takes minutes and holds the request open the whole time. Set your client's timeout accordingly.
POST files/decompress extracts an archive into root, overwriting same-named
files and leaving the archive in place. Extraction counts against the server's disk
quota, and an archive that would exceed it fails partway rather than being refused
up front — check you have room first.
Permissions
{
"root": "/",
"files": [{ "file": "start.sh", "mode": "0755" }]
}mode is a string of octal digits, not a number. The node parses it base-8: a
JSON number is rejected outright, and "420" — which looks decimal — is read as
octal 420 and silently sets permissions you did not ask for. Quote it, and write it
the way you would to chmod.
An entry that does not exist is skipped rather than failing the batch, so a 200
does not prove every path you sent was real.
Moving large files
Uploads and downloads do not go through this API at all. You ask for a URL, and then talk to the node directly:
curl -G https://one.terabit.io/api/v1/game-servers/$SERVER/files/download \
-H "Authorization: Bearer tbk_..." \
-d file=/backups/world.tar.gz{
"data": {
"url": "https://node-14.terabit.io:8080/download/file?token=…",
"expires_at": "2026-08-19T14:17:11+00:00"
}
}That URL carries its own credential — send no Authorization header with it —
and lasts 15 minutes. Mint it when you are ready to transfer rather than storing
it; there is no way to extend one.
Anyone holding that URL can fetch that file until it expires. Treat it as a secret, and do not log it.
Uploads are the same shape. GET files/upload returns a URL to POST a
multipart/form-data body to, with the files under the field name files and the
destination as a directory query parameter you append yourself:
URL=$(curl -s https://one.terabit.io/api/v1/game-servers/$SERVER/files/upload \
-H "Authorization: Bearer tbk_..." | jq -r .data.url)
curl -X POST "$URL&directory=/plugins" -F "files=@EssentialsX.jar"The node enforces its own per-file size cap and rejects anything over it.
Note that GET files/upload needs game-servers.files.manage, not the read
ability. It reads like a read and is not one — what it hands out is a credential to
write.
Why it works this way: a multi-gigabyte world archive streamed through the API would hold a request worker open for the length of the transfer, and the node can already serve it directly. The rate limit is on the control plane for the same reason — one call buys a transfer of any size.
Fetching a URL onto the server
POST files/pull has the node download a URL straight into the server's files. The
bytes never pass through you or through us, which is what makes it the right way to
install a 2 GB mod pack.
{ "url": "https://cdn.example.com/modpack.zip", "root": "/mods" }Accepted, not finished. The node answers immediately and fetches in the background:
{ "ok": true, "message": "Download started.", "identifier": "9f2c…" }Watch it with GET files/pull, which lists what is still running with a progress
fraction between 0 and 1. A finished download disappears from that list
rather than appearing as complete — an empty list means "nothing running", not
"everything succeeded". Check for the file itself to confirm.
DELETE files/pull/{identifier} cancels one and discards what it fetched. The node
answers the same way whether or not the identifier matched, so a 200 means "no
such download is running now".
What it refuses
A 409 means the node would not start it, and message says which:
- remote downloads are switched off on that node
- the server already has three running
- there is not enough disk space
A 422 means the URL itself was rejected. As well as being malformed, a URL is
refused for pointing anywhere internal — private, loopback, link-local, or cloud
metadata ranges. That check runs here and again on the node, and the node is the
authority: a URL that passes here may still be refused there.
Status codes
| Status | Meaning |
|---|---|
200 | Done, or — for a write — the node accepted it |
206 | Your window, with a Content-Range |
403 | The key lacks the file ability, or the server is not yours |
404 | No such file or directory |
409 | The node refused to start a download |
416 | The window starts past the end of the file |
422 | Bad path, oversized inline read, or the server is not provisioned |
502 | The node did not answer, or refused the instruction |
The distinction worth building on is 404 against 502. A 404 is the node
telling us the path is not there. A 502 is the node not answering at all — the
file may well still exist. Retry a 502; never treat one as "the file is gone" and
recreate it.
A worked example
Rewriting a config and restarting, with two keys — one that can write files, one that can restart — rather than one key that can do both:
SERVER=3f2a1c9e-5b4d-4a8e-9c7f-1d2e3a4b5c6d
BASE=https://one.terabit.io/api/v1/game-servers/$SERVER
curl -sG $BASE/files/contents -H "Authorization: Bearer $FILES_KEY" \
-d file=/server.properties > server.properties
sed -i 's/^max-players=.*/max-players=40/' server.properties
curl -X PUT "$BASE/files/contents?file=/server.properties" \
-H "Authorization: Bearer $FILES_KEY" \
-H "Content-Type: text/plain" \
--data-binary @server.properties
curl -X POST $BASE/power -H "Authorization: Bearer $POWER_KEY" \
-H "Content-Type: application/json" -d '{"signal":"restart"}'Read Power actions for what restart does and does
not promise.