SCUMServer Automation
Developers · SSA Bridge protocol
Developers

Updated Describes manager 5.11.2 and SSA Bridge 2.19.5

SSA Bridge protocol

This page is for developers building their own tool, library or SDK against the SSA Bridge without the manager anywhere in the picture. It describes the wire: how to connect, how to authenticate, how a request and a reply are framed, what an error looks like, how to subscribe to events, and what the bridge will and will not let you do at once.

The bridge is free and complete on its own, and this interface is part of that. There is no key to buy, no endpoint held back and no tier. If you want to write a C#, Python, Go or Rust client for it, nothing here is in your way.

Two things to know before you build on it.

The protocol is still growing. Actions and payload keys are added as the mod grows, and this page describes the shape rather than promising a frozen contract. The last section proposes a protocol version number so that a client can tell an additive change from a breaking one; it is a direction, not something the bridge answers today.

Write your client to ignore what it does not recognise: unknown keys on a result object, unknown event types, unknown error codes. Every addition so far has been additive, and a client built that way survives them.

Related pages: SSA Bridge for what the modules can actually do and what an answer means, SSA Bridge module reference for their vocabulary, and SSA Bridge without the manager for installing it.

Connecting

A plain TCP socket. IPv4 only: the listener is an IPv4 socket, so an IPv6 client cannot reach it even on the same machine.

The default is 127.0.0.1:27717, and both halves come from Mods/SSABridge/SSABridge.config.json, which the bridge writes itself the first time it starts. The keys that govern the socket:

KeyDefaultWhat it does
host127.0.0.1the interface to bind. 0.0.0.0 to accept clients from elsewhere, which also requires a token
port27717the port to bind, see the note below on why it is a starting point
tokenemptythe shared secret a non-loopback client must present. Empty means remote control is off
ratePerSec15sustained cost units per second fed to the game
rateBurst30the size of the burst bucket
maxHeavyPerTick2expensive requests run per game frame
maxSpawnCount50ceiling on the item count in one spawn
spawnMinGapMs350minimum gap between two spawn dispatches
spawnFeedbackMs15000how long a hidden command's late output stays hidden
allowNoExecutorfalselet commands run with nobody online, see cmd
commandPrefixemptythe in-game chat prefix that produces chat_command events
logLevelinfoerror, warn, info, detail or trace

Several of these have guard rails, and knowing them saves a confusing afternoon:

Because of the first of those, do not read the port out of the config file. Read Mods/SSABridge/SSABridge.status.json, which the bridge writes after it has actually bound:

{"version":"2.19.3","host":"127.0.0.1","port":27717,"machineId":"..."}

machineId identifies the Windows install and is what a standalone owner pastes into their account to be issued a licence token. It is not a secret, and it is not part of this protocol.

If your client cannot see the server's filesystem, ask the owner for the port and offer a scan of the twenty ports above it as a fallback.

Keep one connection open and reuse it. The bridge holds at most 24 connections at once, and a new one buys you nothing: everything funnels into one queue and runs on the game thread. A connection per request will hit the ceiling on a busy client and be refused.

Authentication

Authentication is per connection, and where you connect from decides whether you need it.

From loopback (127.0.0.0/8) there is no authentication. A client on the same machine as the server can send anything the moment the socket is open. This is deliberate: the bridge treats the box it runs on as trusted, and out of the box it listens nowhere else.

From anywhere else, the first thing you send is auth.

{"id":"1","action":"auth","token":"the token from SSABridge.config.json"}
{"id":"1","ok":true,"result":{"authed":true}}

A wrong token answers {"id":"1","ok":false,"error":"bad_token"}, and the attempt is logged with your source IP. Repeated attempts are rate limited and answer rate_limited, so a client that retries in a tight loop will be told to slow down rather than allowed to guess.

Until you have authenticated, one action works and everything else is refused:

A remote connection has ten seconds to authenticate. After that the bridge sends an unauthorized line explaining why and closes the socket. Send auth first, before your version handshake or anything else.

Three more things worth building around:

The frame

Newline delimited JSON. One JSON object per line, in both directions, \n as the terminator. A trailing \r on an inbound line is tolerated and stripped, so \r\n works. Empty lines are ignored.

{"id":"1","action":"status"}\n

Four rules that will bite a client that does not know them:

A line must be one complete JSON object. A line that stops early, with an unclosed brace, an unclosed string or a key with no value, is refused as bad json. Nothing partial is ever executed. Any real JSON serialiser satisfies this; a client assembling strings by hand may not.

Never put a raw newline inside a string value. The reader splits on \n before it parses anything, so a literal newline inside a chat message cuts your request in half. Escape it as \n inside the JSON string, which a serialiser does for you.

Outbound lines never contain a raw newline. The bridge flattens newlines and carriage returns to spaces in everything it sends, so reading up to the next \n always gives you exactly one message.

Values are read strictly. A field the bridge expects to be a number must arrive as a JSON number. A string, a null or a missing value is refused rather than read as zero, which matters most for coordinates, where zero is a real place on the map.

Encoding is UTF-8. \uXXXX escapes in a request are decoded to UTF-8. Nesting deeper than 64 levels is refused.

A request

{"id":"7","action":"cmd","command":"Announce Restart in 5 minutes","executor":"auto","hide":true,"caller":"my-sdk"}
FieldTypeNotes
idstringechoed back on the reply, and how you correlate the two. A number is accepted and comes back as a string. Absent means the reply carries "0"
actionstringrequired, see the table below
executorstringon cmd, spawn and batch: a 17 digit Steam ID, or auto. Defaults to auto
hideboolon cmd, spawn and batch: hide the command's feedback from the player it runs through. Defaults to true
callerstringa label for the bridge's own activity log, so an owner reading it can see who ran what. Optional and free form

Use your own ids and make them unique per connection. The bridge does not check them, and it does not mind two requests carrying the same one; you will simply not be able to tell the replies apart.

The actions

ActionSendsAnswers
statusnothingversion, port, player count, uptime, licence state
authtoken{"authed":true}
list or playersnothingan array of the players in the world
wheresteamid (optional)that player's position
namesteamid (optional)that player's name
cmdcommandruns one of SCUM's admin commands, and returns its output
spawnitem, count, x y zspawns an item, with pacing
batchcommandsruns several admin commands through one player
chattext, channel, targets, excludesends a chat line into the game
consolecommandruns an Unreal console command
groundzx, y, fromZthe ground height under a point
tracediagx, ydiagnostics for the trace machinery
subscribeeventsturns on push events for this connection
setcmdprefixprefixturns the in-game chat command intercept on or off
modulesnothingevery module, its settings, its schema and its status
module_configmodule, configreplaces one module's settings
module_datamodule, whatasks one module for a report
module_commandmodule, whattells one module to do something

The last four are where nearly all the capability lives. See The module vocabulary.

status

The cheapest way to check that the bridge is answering.

{"id":"1","action":"status"}
{"id":"1","ok":true,"result":{"version":"2.19.3","port":27717,"players":3,"uptimeSec":8412,"licensed":false,"licenseExp":0}}

From an unauthenticated remote connection the result is {"version":"2.19.3"} and nothing else.

list and players

The same action under two names, for the players who are actually in the world.

{"id":"2","ok":true,"result":[{"steamid":"7656119...","name":"Nikolka","x":-410233.1,"y":88120.5,"z":3184.0}]}

A player whose Steam ID could not be read is left out of the list rather than published with a blank one, so every entry can be indexed by steamid.

where and name

Both take an optional steamid. With no steamid they answer about an arbitrary player, the next one in a rotation, not about "the server". That is rarely what a client wants; pass the ID.

Neither is an error when the player is offline. Both answer player not ready, which covers both "not on the server" and "connected but their prisoner is not in the world yet".

cmd

Runs one of SCUM's admin commands through a player, and gives you back what the game printed.

{"id":"3","action":"cmd","command":"ListVehicles"}
{"id":"3","ok":true,"result":{"executor":"7656119...","output":["Vehicle 1: ...","Vehicle 2: ..."]}}

Write the command without the leading #. The hash is the chat prefix and the game's own call wants the bare verb. The bridge strips one leading hash if you send it, so both spellings work, but send the bare verb.

A command runs through a connected player, briefly and invisibly. With nobody online there is nothing to run it through, and the bridge answers no_executor. The owner can opt into a fallback with "allowNoExecutor": true, and then an empty server answers:

{"id":"3","ok":true,"result":{"executor":"none","dispatched":true,"confirmed":false,"output":null}}

dispatched is not confirmed. It means the command was handed to the game and nothing more. There is no executing player, so nothing reports back, and output is null because output is captured from a player's chat and there is no player. If your tool charges somebody, gives an item or marks a task done, act on confirmed, never on ok alone.

spawn

{"id":"4","action":"spawn","item":"BP_Weapon_M9","count":2,"x":-410000,"y":88000,"z":3200}
{"id":"4","ok":true,"result":{"executor":"7656119...","spawned":2,"requested":2,"countNote":"...","item":"BP_Weapon_M9","output":["Spawned BP_Weapon_M9."],"status":"spawned"}}

Four things about this reply:

Spawns are paced deliberately: at most one every spawnMinGapMs (350 ms by default), and never more than the frame's heavy budget. A burst is spread out rather than refused.

batch

{"id":"5","action":"batch","commands":["Announce one","Announce two"]}
{"id":"5","ok":true,"result":{"executor":"7656119...","count":2,"ok":2}}

count is how many were sent, ok how many dispatched cleanly. An empty or missing commands array is refused, because a success that ran nothing reads exactly like a success that ran everything.

chat

{"id":"6","action":"chat","text":"Nikolka: hello","channel":"global","targets":["7656119..."],"exclude":[]}
{"id":"6","ok":true,"result":{"channel":2,"delivered":1}}

console

Runs an Unreal engine console command, which is not the same thing as a SCUM admin command. It answers {"ran":true} or console_failed, and it can tell you nothing about whether the command was a real one, because the engine returns nothing either way.

groundz and tracediag

groundz traces down at a point and returns the ground height there.

{"id":"8","action":"groundz","x":-410000,"y":88000,"fromZ":3500}

Without fromZ it traces from the sky, which indoors finds the roof. With fromZ, the height you already know something is at, it finds the surface under that, and if the two answers differ by more than half a metre the reply carries both plus a note saying so.

tracediag runs the same probe on seven channels and reports what each returned. It is a diagnostic, not something to build on.

A response

Every reply to a request carries id and ok.

Success:

{"id":"7","ok":true,"result":{"...":"..."}}

result may be an object, an array or a string, depending on the action.

Failure:

{"id":"7","ok":false,"error":"no_executor","reason":"nobody is on the server to run this through - turn on \"allowNoExecutor\" in SSABridge.config.json to let the bridge dispatch with no executing player"}

Some replies carry extra keys beside result rather than inside it. On module_command and module_data those are changed, note and reach, and they are described under The module vocabulary. Treat any key you do not know as something to ignore, not as an error.

Replies are not ordered. Correlate by id and never by arrival. Two things cause that: a request the bridge cannot afford this frame is put back on the queue and runs on a later one, so a cheap request behind an expensive one overtakes it; and a spawn's reply may be held for a few seconds waiting for the game to speak.

Events arrive on the same socket, interleaved with replies. An event object has an event key and no id, so the discriminator is simple: if there is an id, it is a reply to something you sent; if there is an event, it is a push.

Errors

The field to branch on is error, and there is one thing you must know about it before you write that switch.

error is a stable code only when it comes from the bridge itself. When a module refuses, error carries the module's own sentence. The modules answer in prose on purpose, so that a refusal can be shown to a person, and there is no code in front of it. So the rule for a client is: match error against the codes you know, and if it matches none of them, treat it as text to display rather than as an unknown code to fail on.

reason is always prose. Never switch on it. It is written for a person, and its wording will change.

These are the codes the bridge itself produces. They are stable, and existing ones will not be renamed.

CodeWhat it meansWhat a client should do
bad jsonthe line was not one complete JSON objectfix the serialiser. The reply carries "id":"0" because the id could not be read either
unknown actionno such action in this buildthe reason lists the actions it does have
unauthorizedremote client that has not sent authsend auth first
remote_disabledremote client, and the owner has configured no tokennothing to retry. Tell the owner to set one
bad_tokenwrong tokendo not retry in a loop, it is rate limited
rate_limitedtoo many auth attempts too fastwait a second
busythe bridge is already holding 1024 unanswered requestssend it again shortly. Usually means your client is outrunning the server
line_too_longover 64 KB with no newline in itthe connection is closed after this reply. Split the payload
too_many_clients24 connections already openreuse a connection instead of opening one per request
tamperedthe mod DLL does not match its signaturenothing to retry, the file is not the one that was shipped
unknown_moduleno module with that idthe modules action lists every id
unknown_commandthe module refused and gave no reasoneither the verb does not exist or the module recorded no reason, and from outside those look the same
no_dataa module report came back empty with no reasonas above, for reads
missing_configmodule_config with no config objectread modules first, change what you need, send the whole set
missing_command, missing item, missing text, missing commanda required field was absentthe reason names it
missing_xy, bad_xy, bad_xyzcoordinates absent, not numbers, or not usablesend all of them, as JSON numbers
no_groundnothing solid in that columnthe place may be outside the world or its level may not be loaded
no ground below that heightthe same trace, but you passed a fromZfromZ may already be below the floor. Note that this action answers under two different codes depending on that field
no_executornobody online to run the command throughwait for a player, or ask the owner about allowNoExecutor
player not readythat Steam ID is not on the server, or has not spawned inretry later
console_failedthe console entry point could not be reachedsays nothing about the command itself

Four of these arrive with "id":"0" and are not a reply to any request you sent, because they come from the connection layer rather than the dispatcher: line_too_long, busy, too_many_clients and the unauthorized sent when the ten second grace period expires. A client that only ever looks up replies in a pending-request map will drop them silently. Handle an id you do not recognise by logging it.

line_too_long, too_many_clients and the timeout unauthorized are each followed by the connection closing.

Subscriptions and events

The bridge pushes events, and you have to ask for them. A connection that has not subscribed receives replies only.

{"id":"9","action":"subscribe","events":["player_join","player_leave","chat_command"]}
{"id":"9","ok":true,"result":{"subscribed":["player_join","player_leave","chat_command"]}}

Compare what came back with what you asked for. A name the build does not know is dropped silently rather than refused, so the answer is the list of what is actually on. That is also how a client discovers, on an older or newer build, which of the events it wants exist.

Subscriptions are per connection. Reconnect and you subscribe again. There is currently no way to unsubscribe: the flags are only ever turned on, and the way to stop receiving events is to close the connection. If your client wants to pause a feed, do it on your side.

player_join and player_leave

{"event":"player_join","steamid":"7656119...","name":"Nikolka"}
{"event":"player_leave","steamid":"7656119...","name":""}

Both come from a poll that runs about once a second, so they are not instantaneous and they carry no timestamp.

player_leave is deliberately late. A player's controller can drop out of the ready set for a moment during a respawn or a transient game state, which naively looks like a leave and an immediate rejoin. A leave is only reported after five consecutive misses, so roughly five seconds after the player really went. Build for that: it is the difference between a welcome message firing once and firing every time somebody respawns.

player_leave carries an empty name. The player is gone, so there is nothing left to read it off. Keep your own map from the join event if you need the name.

chat_command

{"event":"chat_command","steamid":"7656119...","name":"Nikolka","channel":2,"text":"/kit starter"}

This one has a prerequisite: a prefix must be set, either as commandPrefix in the config or at runtime with setcmdprefix.

{"id":"10","action":"setcmdprefix","prefix":"/"}

An empty prefix turns the intercept off. With a prefix set, a player's chat line that starts with it (the prefix at the very start, with a non-space immediately after) is removed from chat entirely, so nobody sees it, including the sender, and forwarded to every subscribed connection exactly once. Repeats of the same line within a second and a half are treated as one.

text includes the prefix. channel is the numeric chat channel the player typed in.

Since the line never reaches chat, the player sees nothing at all unless you answer them. Send a chat with targets set to that one Steam ID.

The outbound backlog

Every connection has an outbound queue capped at 4096 messages, and once it is full the oldest is dropped. That bound exists so a subscriber that stops reading cannot grow the game server's memory, and the consequence is worth designing around: a slow reader loses messages, and the loss is silent, and it is not only events that can be dropped. Read continuously, and do the work somewhere other than the socket thread.

A write that stalls for five seconds drops the connection.

Limits, timeouts and concurrency

There is one game thread, and everything runs on it. Requests are queued off the socket threads and executed on the game thread, which is what makes them safe. It is also the whole reason the limits below exist: a client that outruns them does not slow itself down, it slows the server down for the players on it.

LimitValueWhat happens
Connections24further connects are refused with too_many_clients and closed
Queued requests1024further requests answer busy
Line length64 KB with no newlineline_too_long, connection closed
JSON nesting64 levelsrefused as bad json
Requests per frame16the rest run on the next frame
Heavy work per framemaxHeavyPerTick, 2the pass stops and the rest run on later frames
RateratePerSec 15, rateBurst 30 cost unitsover budget requests are requeued, not refused
Auth grace10 secondsthe connection is closed
Outbound write5 secondsthe connection is dropped
Items per spawnmaxSpawnCount, 50clamped, not refused
Gap between spawnsspawnMinGapMs, 350 msthe spawn waits, it is not refused

The cost accounting is worth understanding, because the way a request is throttled decides whether your client sees anything at all:

Being over budget is not an error. The request is put back at the end of the queue and runs on a later frame. You get no reply until it runs, and no notification that it was deferred. So:

Your client needs its own timeout. The bridge will not time a request out for you, and a request that has been requeued behind a slow one can legitimately take a second or more. Something in the range of ten to thirty seconds is sane; treat an expiry as "unknown", not as "it did not happen", because a deferred request will still run.

On concurrency: you may pipeline as many requests as you like down one connection, and you should. Opening more connections does not buy parallelism, because everything ends up in the same queue on the same thread; it only spends the connection budget. One connection, requests written as you need them, replies matched by id, is the shape the protocol is built for.

The module vocabulary

Four actions cover everything the bridge's 44 modules can do, and this is where a real client spends its time.

Every module ships switched off. Installing the bridge changes nothing about a server until an owner turns something on. A module that is off refuses its own verbs and says which setting to turn on, so a refusal here is usually configuration rather than a bug.

modules

Ask the socket what this build has.

{"id":"11","action":"modules"}

The result is an array, one entry per module:

[{"id":"zones","name":"Zones","enabled":false,"config":{},"schema":{"...":"..."},"status":{"...":"..."}}]

This reply is large. It carries the full schema of every module, so fetch it when you need it rather than on a timer.

module_data and module_command

A read and a write, and both name the module and one string.

{"id":"12","action":"module_data","module":"zones","what":"zones"}
{"id":"13","action":"module_command","module":"zones","what":"move:Outpost A0:-604800:12000"}

The what string is the vocabulary, and it is published in full on SSA Bridge module reference: every read and every verb, per module, with how many colon separated fields each verb takes. That page is generated from the mod, so it describes exactly one build.

What each field means is not in that table, and that is on purpose. Send a verb wrong and the module answers with a sentence naming what it wanted. That refusal is the documentation for the arguments, and it is written to be shown to a person.

A successful module_command:

{"id":"13","ok":true,"result":{"accepted":true,"changed":true,"note":"the countdown is now 600.0 s, was 0.0 s","reach":{"wrote":1,"known":1,"replicated":1,"persists":0,"pushed":1,"atAddress":0}}}

Three optional keys, and they answer questions accepted cannot:

module_data replies the same way. Its payload fills result, and changed, note and reach ride beside it, because two of the modules do their writing through the read path.

On a refusal after a partial write, reach is present on the failure too, which is the one place a caller most needs it: some fields moved and the command still refused.

module_config

{"id":"14","action":"module_config","module":"zones","config":{"enabled":true,"maxRadius":2000}}

This replaces a module's settings wholesale. It does not merge. Read modules first, change what you need in the object you got back, and send the whole set. Sending only the key you changed resets everything else to nothing.

An empty config object is refused by name, because it would delete every setting the module has and the reply would be indistinguishable from a successful write.

The reply is the full modules array again, so you can see the result without a second call.

A minimal client, end to end

connect to 127.0.0.1:27717
  (remote only) send {"id":"a","action":"auth","token":"..."}   and wait for ok
send {"id":"b","action":"status"}                                and check the version
send {"id":"c","action":"subscribe","events":["player_join","player_leave"]}
loop:
  read one line, parse it
  if it has "event"  -> dispatch to a handler, and never block the reader
  if it has "id"     -> resolve the pending request, or log it if unknown
  ignore keys you do not recognise

Everything else is module_data, module_command and the occasional cmd or chat.

Proposed: a protocol version

This is a proposal, not something the bridge answers today. It is here so a client author can build for it now, and so the rule below is public rather than implicit.

The build version (2.19.3 and so on) moves on every rebuild, including rebuilds that change nothing a client can see, so it cannot be compiled against: a client cannot tell "the mod was rebuilt" from "the wire changed". The proposal is a separate integer, changed only when the wire changes.

{"id":"1","ok":true,"result":{"version":"2.19.3","protocol":1,"protocolMin":1,"port":27717,"...":"..."}}

status is the right place for it: it is the one action available before authentication.

What counts as breaking, and what does not

Additive, the number does not move:

Breaking, the number goes up:

Adding a key is additive, renaming one is breaking. A client that ignores what it does not recognise is unaffected by everything in the first list.

The module vocabulary is not part of the protocol version. Which verbs exist is data, not wire shape, and it changes far more often. Ask modules at runtime, and treat a verb that has gone as a refusal to report rather than a version mismatch.

What a client should do with the answer

It answersDo
a protocol you knowproceed
a protocol higher than you knowproceed, ignoring unknown keys, events and error codes. Every addition is additive by the rule above, so a newer build stays usable by an older client. This is the case that makes the scheme worth having
a protocol lower than your minimumrefuse, and tell the user which bridge version they need. Do not attempt to speak an older shape
no protocol key at alltreat it as 1

And the rule that makes all of it true, whether or not this ever ships: ignore what you do not recognise, and never parse prose.


Building something on this? The module reference is the vocabulary, and the standalone bridge page covers installing and configuring it.