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 -fwith 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 holds | The token gains |
|---|---|
game-servers.read | websocket.connect — receive console output |
game-servers.manage | control.console, control.start, control.stop, control.restart |
game-servers.files.read | file.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:
| Event | Carries |
|---|---|
console output | one raw line of server output per frame, no envelope |
status | the server's power state — offline, starting, running, stopping |
stats | a JSON resource snapshot: CPU, memory, disk, network, uptime |
daemon message | a 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
| Event | args[0] | Needs |
|---|---|---|
send command | the command line, as typed | control.console |
set state | start, stop, restart or kill | control.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}"]}| Field | Default | Meaning |
|---|---|---|
id | the path | Your correlation key. Re-subscribing an existing id replaces that tail, which is the cheap way to reseek. |
path | required | Relative to the server's data directory. .., absolute paths and symlinks leaving the root are refused. |
start | "end" | "end", "beginning", or "offset". |
lines | 150 | With 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. |
follow | true | false 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.
- Persist
next_offsetandinodefrom the lasttail datayou actually processed. - On reconnect, subscribe with
start:"offset", that offset, and that inode. - Check
resumein thetail startedframe.rejectedmeans you missed data, andon_mismatchdecided 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}reason | What happened |
|---|---|
rate_limit | The file grew faster than the byte budget could drain it. Delivery resynchronises to a line boundary. |
truncated | The file shrank below your position. Delivery restarts at 0. |
rotated | A different file now answers to this path. Store the new inode. |
resume_rejected | The 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.