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

AbilityReaches
game-servers.files.readbrowsing, reading, and download URLs
game-servers.files.manageeverything 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.properties

Files 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=65536

A 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.properties

There 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

StatusMeaning
200Done, or — for a write — the node accepted it
206Your window, with a Content-Range
403The key lacks the file ability, or the server is not yours
404No such file or directory
409The node refused to start a download
416The window starts past the end of the file
422Bad path, oversized inline read, or the server is not provisioned
502The 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.

On this page