WebSockets

Stream a server's console, follow a file, and send power actions over one websocket.

A server's live output arrives over a websocket on the node, not over this API. One call mints the credential; everything after that is frames on that socket.

The same socket carries two things:

  • the console — what the server process is printing right now, plus the status and resource frames that go with it, and the channel you send commands and power actions back through;
  • a file tail — following any file in the server's directory, tail -f with byte offsets, so a disconnect is recoverable.

Choosing between the tail and GET /logs: /logs is a snapshot of recent stdout, capped and not resumable, so consecutive calls overlap and can miss lines written between them. It is for a glance. The tail follows a file and resumes exactly. Durable log shipping wants the tail.

Getting a credential

curl -X POST https://one.terabit.io/api/v1/game-servers/{server}/socket \
  -H "Authorization: Bearer tbk_..."
{
  "data": {
    "socket": "wss://node-42.terabit.io:8080/api/servers/8f1c…/ws",
    "token": "eyJhbGciOi…",
    "expires_at": "2026-08-24T12:10:00+00:00",
    "permissions": ["websocket.connect", "file.read-content"]
  }
}

permissions is what your token can actually do, derived from your key's abilities — you cannot ask for more:

Your key holdsThe token gains
game-servers.readwebsocket.connect — receive console output
game-servers.managecontrol.console, control.start, control.stop, control.restart
game-servers.files.readfile.read-content — required to tail a file

So a read-only key gets a socket it can listen on and not send through. And tailing needs the file-read ability, even though the tail arrives here rather than through the file endpoints.

Connecting

Open socket and authenticate in-band. There is no bearer header on a websocket:

{"event": "auth", "args": ["<token>"]}

Wait for auth success before subscribing to anything.

You do not need an Origin header. A missing one is accepted, which is what makes this usable from a script rather than only from a browser. If you do send one it has to match, so it is simpler not to.

Tokens are short-lived. The socket emits token expiring about 60 seconds ahead — mint a fresh one and re-send auth on the same socket, and any tails already running continue untouched. A token minted before the node last restarted is refused, so mint on demand rather than caching.

Every frame in both directions has the same shape: an event name and an args array of strings. Structured payloads are JSON encoded into one of those strings rather than sent as an object.

Console

What arrives

Once authenticated, these are pushed without asking:

EventCarries
console outputone raw line of server output per frame, no envelope
statusthe server's power state — offline, starting, running, stopping
statsa JSON resource snapshot: CPU, memory, disk, network, uptime
daemon messagea line from the node itself rather than the server process

console output is unchanged from Pterodactyl: a bare string, no envelope. The tail events below use structured JSON instead, because a machine consumer needs to know which subscription, which bytes, and whether anything was missed — none of which a bare string can carry.

Install, backup and transfer events ride the same socket and are gated on permissions a customer key is not granted, so you will not see them.

What you can send

Eventargs[0]Needs
send commandthe command line, as typedcontrol.console
set statestart, stop, restart or killcontrol.start / control.stop / control.restart
send logs—websocket.connect
send stats—websocket.connect
{"event": "send command", "args": ["say hello"]}
{"event": "set state", "args": ["restart"]}

send logs replays the recent console backlog as ordinary console output frames, which is how you fill a fresh terminal on connect. send stats asks for a stats frame immediately rather than waiting for the next one.

A rejected send command is silent. There is no error frame and no acknowledgement of any kind when one is dropped, and it is dropped in three ordinary situations: your token lacks control.console, the server is offline, or it is starting and the container is not yet attached. An unknown event name is discarded the same way — which is what lets a client talk to an older node safely, and what makes a typo in event look exactly like a server that is ignoring you.

Watch status and send only while running, and treat the absence of a reply as carrying no information.

set state differs in one respect: an action that is not one of the four names is an error rather than a silent drop. Missing permission is still silent.

Tailing a file

args[0] is a JSON string, not an object — the frame's args is an array of strings, so the payload is encoded into one of them.

{"event": "tail file", "args": ["{\"id\":\"log\",\"path\":\"logs/latest.log\",\"lines\":150}"]}
FieldDefaultMeaning
idthe pathYour correlation key. Re-subscribing an existing id replaces that tail, which is the cheap way to reseek.
pathrequiredRelative to the server's data directory. .., absolute paths and symlinks leaving the root are refused.
start"end""end", "beginning", or "offset".
lines150With start:"end", how much backlog first. 0 means follow from here.
offset—Required with start:"offset".
inode—From a previous frame. Supply this when resuming.
followtruefalse delivers the window, marks the last frame eof, and drops the tail.

Stop one tail, or all of them:

{"event": "untail file", "args": ["log"]}
{"event": "untail file", "args": [""]}

What you receive

tail data carries the lines and, more importantly, where they came from:

{"id":"log","seq":1,"inode":"2049:1234567",
 "offset":10420000,"next_offset":10424096,"size":10485760,
 "lines":["[12:00:01] Done (3.2s)!"],"truncated_lines":[]}

seq is monotonic per subscription across all frame types, so frame loss is detectable independently of byte arithmetic.

Resuming

This is what the byte offsets are for, and it is where an integration usually goes wrong.

  1. Persist next_offset and inode from the last tail data you actually processed.
  2. On reconnect, subscribe with start:"offset", that offset, and that inode.
  3. Check resume in the tail started frame. rejected means you missed data, and on_mismatch decided where you landed instead.

inode is not optional. A byte offset is only meaningful for the file it was issued against, and after a log rotation the same number points into a different file. Omit the inode and the node cannot tell — it will honour your offset against whatever is there now and hand you the wrong bytes inside a perfectly well-formed frame. Supply it and a stale resume is refused instead.

An offset past the current end of file is refused the same way.

When you lose bytes, you are told

Losing data never passes silently. A tail gap frame names the range and why:

{"id":"log","seq":7,"reason":"rate_limit",
 "from":10424096,"to":18000000,"bytes":7575904}
reasonWhat happened
rate_limitThe file grew faster than the byte budget could drain it. Delivery resynchronises to a line boundary.
truncatedThe file shrank below your position. Delivery restarts at 0.
rotatedA different file now answers to this path. Store the new inode.
resume_rejectedThe offset you asked for could not be honoured.

One event with a discriminator rather than four, because the client-side meaning is identical in every case: your stored offset is no longer valid, here is what happened.

A tail error ends that subscription and carries a machine-readable code — not_found, is_a_directory, unsupported_file_type, denied, limit, disabled, invalid_request or io_error. Errors are scoped to the subscription; other tails and the console are unaffected.

This is the difference from the console: a tail tells you when it failed, a dropped send command does not.

What cannot be tailed

Directories, symlinks, FIFOs, sockets and devices, files matching the egg's denylist, and files whose opening bytes look binary. The denylist is re-checked when a rotation reopens the path.

Limits

The node caps how many tails one connection may hold, how fast it will feed them, and how far behind it will let you fall before signalling a gap instead. They are per-node configuration rather than a promise of this API, so treat a limit error and a rate_limit gap as normal conditions to handle rather than faults.

On this page