mssgs Game SDK
Let a native game find the mssgs client on the same machine, or link a browser or phone game through your own backend. Show what the player is playing with a Join now button, and check whether they are in your community, without making them hand over their whole social graph.
Webhooks & Integrations
Send messages and build interactive bots with webhooks and triggers.
MCP
Give AI assistants local, controlled access to your workspace.
Overview
The mssgs desktop app runs a small local HTTP bridge that a game on the same machine talks to. Your game never talks to our servers, never sees an account password or token, and can never post as the player. It talks to the copy of mssgs the player is already signed in to, and that copy decides what to answer.
What you can do with it:
- Detect that mssgs is installed and somebody is signed in.
- Read who the player is: user_guid, username, avatar.
- Ask "is this player in community X?" and what role they hold there.
- Publish a "Playing …" status with a Join now button for others.
- Receive a join hand-off when somebody presses that button.
Two ways in
A native desktop game talks to the local bridge; that is what the next sections describe. A game in a browser or on a phone cannot reach that bridge. For those, your own backend publishes, for players who linked their mssgs account through a QR or an eight-character code: see Browser and phone games, with CozyCity as the first example. The playing status itself is the same block either way.
Minimal disclosure, by default
The scopes are deliberately unequal. If all you need is "is this person in our community", you ask for membership.query and name the server_guid yourself: you get yes/no plus their roles there, and learn nothing about the rest of their communities. The full list sits behind a separate, higher scope the player has to approve on its own.
Finding the client
The bridge listens on 127.0.0.1 only, on the first free port in a small range. Try them in order until one answers: 7440, 7441, 7442, 7443. Development builds of mssgs listen on 7540–7543 instead, so a test build never answers a real game's calls.
http://127.0.0.1:7440/mssgs/v1/hello
No token needed, and the answer says nothing about the player, only that mssgs is here and whether anybody is signed in.
{
"product": "mssgs",
"api": 1,
"client": "desktop",
"version": "14.2.20015",
"platform": "darwin",
"signed_in": true,
"scopes": ["identity", "staff", "membership.query", "servers.list", "presence.write"]
}
Check product === "mssgs" and api before going further. If none of the four ports answer, mssgs is not running. Just offer your normal experience rather than making the player wait.
Scopes & privacy
The five scopes give away very different amounts. That is not incidental; it is the whole design. Ask for as little as you can, working down this table.
| Scope | What it allows | What the player gives up |
|---|---|---|
presence.write |
Show what they are playing | Nothing. This scope only writes; it reads no account data at all. |
identity |
Who the player is | user_guid, username, display name, avatar URL. |
staff |
Staff / moderator flags | Two booleans, on top of identity. Separate because a game showing a name has no business knowing the player moderates communities. |
membership.query |
Check a community you already know | For a server_guid you name: yes/no, its name, and the roles that player holds there. Nothing about any other community. |
servers.list |
Every community they are in | The full list: guids, names, icons and roles. This is the expensive one: ask for it only if you truly need it. |
Most games need two of them
identity and presence.write cover "who are you" and "show what you are playing", which is nearly every integration. Add membership.query if you want to tie a reward to membership of your community. You almost never need servers.list, and the player sees it highlighted in red.
Checking membership
This is the alternative to "give me the whole list". You name the server_guid of your own community (which you already know) and get an answer about that alone.
curl -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:7440/mssgs/v1/membership?server_guid=ca94ecc…f4g02"
# a member:
# {"server_guid":"ca94ecc…","member":true,"name":"Acme Fans","is_owner":false,
# "roles":[{"guid":"0aa32…","name":"Pro"}]}
# not a member, and nothing else:
# {"server_guid":"…","member":false}
A "no" is exactly that and nothing more. You may pass up to 10 guids per call (repeat server_guid or comma-separate them), which returns a results array. The @everyone group is never in roles: it is true of every member, so it tells you nothing.
Publishing a playing status
One PUT puts the "Playing …" line under the player's name, everywhere their communities see them.
{
"name": "Space Raiders",
"details": "Sector 7",
"state": "In a raid",
"role": "Gunner",
"started_at": 1755859200000,
"party": { "size": 3, "max": 4, "kind": "party" },
"join": { "secret": "raid-42" }
}
Only name is required. The response tells you how long the status lives and how often to heartbeat:
{ "ok": true, "expires_in_ms": 90000, "heartbeat_every_ms": 30000 }
Heartbeat, or the status disappears
A status with no sign of life for 90 seconds is cleared automatically. That is deliberate: if your game crashes, the player is not left "playing" for hours. Send a POST /mssgs/v1/activity/heartbeat every 30 seconds, and DELETE /mssgs/v1/activity on a clean shutdown.
Player count and role
party.kind decides which sentence is rendered, because the same two numbers do not mean the same thing. A squad of four is not a server with four players on it.
kind |
Renders as | For |
|---|---|---|
party (default) | 3 of 4 in the party | a squad, crew or group |
server | 4/100 players | a game server (FiveM, a community server) |
lobby | 4/100 players | a lobby before the match starts |
match | 4/100 players | a match or round in progress |
role (up to 48 characters) is what the player is playing as: a job, class or character. It gets its own field rather than another sentence in state, because it is shown as a label beside the player count.
details and state are capped at 128 characters each, name at 64. Line breaks and control characters are stripped. An icon URL is deliberately not supported: it would be fetched by every client that renders the line, which turns a status into a beacon reporting every member of every community the player is in back to your server.
The Join now button
Put a join block in your activity and other members get a Join now button beside the status. There are two ways, and you can combine them.
1. A secret (for native games)
Set {"join":{"secret":"raid-42"}}. When somebody presses Join now, that secret is delivered to their own copy of your game, on their own machine, matched by the same game_id. No URL is opened and no scheme handler is invoked. Your game collects it with:
{
"events": [
{ "seq": 1, "type": "join", "secret": "raid-42",
"from": { "user_guid": "62e377…", "username": "mssgs-test-1" } }
],
"cursor": 1
}
Poll with ?since=<cursor> so you see each event once. If the presser's game is not running, nothing is delivered, which is a good reason to also offer a URL.
2. An https URL (for web games and lobby links)
Set {"join":{"url":"https://play.example.com/s/abc"}} and the button opens that link. Only https is accepted. A custom scheme (steam://, mygame://, file://) is refused: that block lands on every member's screen, and such a URL is a way to make somebody else's machine invoke a local handler with arguments you chose.
Everything in join is public
The join block is broadcast to everybody who can see the player's status; that is the entire point of a Join now button. So treat it as a lobby code, not a credential. Never put anything in it that has to stay secret, and expire your codes.
FiveM
FiveM has no HTTP in its client-side Lua runtime, so a resource talks to the bridge through NUI, a CEF view, which sends an Origin. The bridge accepts those origins explicitly: https://cfx-nui-<resource> and the older nui://<resource>. Ordinary web pages stay refused, and a page on the open web cannot claim that origin; the browser sets it itself.
-- The NUI page does the HTTP; Lua only sends it the data.
CreateThread(function()
while true do
SendNUIMessage({
action = 'mssgs:publish',
players = GetActivePlayers and #GetActivePlayers() or 0,
maxPlayers = GetConvarInt('sv_maxclients', 100),
job = exports['qb-core'] and 'Police' or nil
})
Wait(30000) -- heartbeat: the status expires after 90s
end
end)
const BASE = 'http://127.0.0.1:7440/mssgs/v1'; // try 7440-7443
let token = null;
async function authorize () {
const res = await fetch(`${BASE}/authorize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
game_id: 'fivem.lossantos.rp',
name: 'Los Santos Roleplay',
scopes: ['presence.write'] // nothing more is needed here
})
});
const started = await res.json();
if (started.status === 'approved') { return started.token; }
// The player now sees the permission dialog in mssgs.
for (let i = 0; i < 180; i += 1) {
await new Promise((r) => { setTimeout(r, 1000); });
const poll = await (await fetch(`${BASE}/authorize/${started.request_id}`)).json();
if (poll.status === 'approved') { return poll.token; }
if (poll.status !== 'pending') { return null; }
}
return null;
}
window.addEventListener('message', async (event) => {
if (event.data.action !== 'mssgs:publish') { return; }
if (!token) { token = await authorize(); }
if (!token) { return; }
await fetch(`${BASE}/activity`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({
name: 'FiveM',
details: 'Los Santos Roleplay',
role: event.data.job, // "Police"
party: { size: event.data.players, max: event.data.maxPlayers, kind: 'server' },
join: { url: 'https://cfx.re/join/abc123' } // your cfx.re link
})
});
});
The result: Playing FiveM · Los Santos Roleplay · 4/100 players · Police, with a Join now button that opens your cfx.re link.
Ask for presence.write only
A playing status needs nothing else: that scope reads nothing at all. If you want to tie an in-game reward to membership of your mssgs community, add membership.query and name your own server_guid; you still learn nothing about the player's other communities.
A server you play on is not automatically trusted
Any FiveM server can run client resources, so any server somebody joins can ask for permission. That is exactly why a dialog sits in between, naming the resource: the player decides, not the server.
Browser and phone games: linking through your backend
A game in a browser tab or on a phone cannot reach the bridge above. It runs on the player's desktop, and three walls stand in between: the bridge refuses every request carrying a browser Origin, Chrome puts a permission prompt in front of a public page fetching 127.0.0.1 and Safari refuses outright, and a phone has no path to a desktop's loopback at all.
So the direction flips. Your own backend already knows who is playing, and it tells mssgs, for players who linked their mssgs account to your game. The link is approved in the mssgs app, never in your game, and it creates a link, never a session: nothing below can sign anyone in or act as the player. Your game client never sees a key and never talks to mss.gs. The first game on this path is CozyCity, a city builder that ships as a WebGL page and an iPhone app with no desktop build; the examples below are its own.
1. Register your game
Registration goes through us, not a portal: send us your game_id (for example com.deverence.cozycity), the name and icon the player should see on the approve sheet, and your backend hostnames via the contact page. You get one backend key back, shown once; we keep only a digest. The key belongs on your server and nowhere else. It can be rotated at any time, and the old one stays valid for 24 hours so a deploy can roll.
The name and icon on the sheet always come from the registration, never from the request. Otherwise a phishing link could dress a link request up as any game it liked. The hostnames bound where a join.url may point, see below.
2. Link a player
The player picks Connect mssgs in your game. Your game asks your backend, and your backend asks us:
curl -X POST https://ams1-gateway.mss.gs/game-sdk/v1/link/start \
-H "Authorization: Bearer $BACKEND_KEY" \
-H "Content-Type: application/json" \
-d '{ "player_ref": "player-8812", "player_name": "René\u2019s city" }'
# {"link_code":"K7PQ2XM4","device_code":"…","qr_url":"https://mss.gs/gl/K7PQ2XM4",
# "deep_link":"mssgs://link-game/K7PQ2XM4","expires_in":600,"interval":5}
player_ref is your own stable id for that player (up to 128 characters), not for a session or a match; player_name (up to 64) is what the sheet shows as "Player: …". Hand your game client only link_code, qr_url and deep_link. device_code is your polling handle and stays on the server.
Your game then shows three things at once, because the player could be anywhere:
- The QR of
qr_url. A phone with mssgs opens it straight into the app's approve sheet. Without the app it lands on a page on mss.gs that shows the code and offers the download. - An "Open in mssgs" button with
deep_link, for a desktop browser sitting next to the desktop app. It is the only external URL your game ever needs to open. - The code itself, in two groups of four, to type under Settings → Game Activity → Link a game. The alphabet has no 0/O or 1/I, so typing it rarely goes wrong.
What the player sees in mssgs, drawn by the app from the registration:
Connect CozyCity to your mssgs account?
CozyCity will be able to show what you are playing as your mssgs status. It will not see your messages, your friends or your servers, and it cannot post as you.
Player: René's city · Connect / Not now
Meanwhile your backend polls every interval seconds (faster answers 429 SLOW_DOWN) until the status flips. A code works once and expires after ten minutes:
curl -X POST https://ams1-gateway.mss.gs/game-sdk/v1/link/poll \
-H "Authorization: Bearer $BACKEND_KEY" \
-H "Content-Type: application/json" \
-d '{ "device_code": "…" }'
# {"status":"pending"}
# {"status":"denied"} # the player chose Not now
# {"status":"expired"}
# {"status":"linked","link_guid":"…","user":{"user_guid":"62e377…"}}
Store link_guid against your player; from now on it is the address you publish for. The answer carries only the user_guid; a username is added only when your registration holds the identity.link scope, and there is nothing beyond that. A second approval for the same player_ref replaces the earlier link, so one player of your game is one mssgs account. The same mssgs account may link to several games and to several player_refs of one game (a family iPad).
3. Publish the playing status
The same block as on the bridge, with the same rules and the same caps, only now per link and with your backend key:
{
"activity": {
"name": "CozyCity",
"details": "Lantern Hollow",
"state": "Day 12 · 34 residents",
"started_at": 1788901000000,
"party": { "size": 6, "max": 40, "kind": "server" },
"join": { "url": "https://cozycity.net/game/?share=…" }
}
}
200 {"published":true,"changed":true} # the block changed and was broadcast
200 {"published":true,"changed":false} # identical to what was stored; only the TTL was refreshed
204 # stored, but the player is not online in mssgs right now
410 {"error":"LINK_REVOKED"} # the player disconnected: drop the link
Treat 200 and 204 the same: stored. { "activity": null } clears the block, send that when the player leaves. One difference from the bridge: the host of join.url must be one of your registered backends (or a subdomain of one), or you get 400 INVALID_PAYLOAD. So a backend cannot put a Join now button on a player's status that leads somewhere that player never played.
Heartbeat every 60 seconds, TTL 120
A published status lives 120 seconds without a new message and then drops on its own. So resend the same block every 60 seconds; an unchanged block costs nothing and only refreshes the TTL. If your heartbeat stops, the "Playing …" line stops, which is exactly the point.
With hundreds of players online, heartbeat in one call, up to 100 items at a time. Each item gets its own status, so one player who disconnected in mssgs never stops the other ninety-nine:
{ "items": [ { "link_guid": "…", "activity": { "name": "CozyCity", "details": "Lantern Hollow" } },
{ "link_guid": "…", "activity": null } ] }
// → 200 { "results": [ { "link_guid": "…", "status": 200, "changed": false },
// { "link_guid": "…", "status": 410 } ] }
How the status is shown
- Identical to a bridge status: Playing CozyCity · Lantern Hollow · 6/40 players, with Join now when there is a
join.url. The block is stampedvia: "backend"server-side, so a client can add "Shared by the game's server". - Only while the player is online in mssgs. With no mssgs client open, the account is offline and stays offline; your backend cannot make somebody look present. That also stops this route from becoming an "is René at his computer" beacon.
- Precedence: an in-app game > a game on the bridge > your backend. If the player sits down to chess inside mssgs while your backend keeps heartbeating, chess wins, not whoever wrote last.
Disconnecting
The player sees every link under Settings → Game Activity → Linked games, with your icon and name, the player name from your game, when it was linked and when it last published, and a Disconnect button. After that your next publish answers 410 LINK_REVOKED; that is how your game learns. Drop the link_guid and offer "Connect mssgs" again. From your side, end a link with DELETE /game-sdk/v1/links/{link_guid}.
Limits
Per link, a change counts at most every 2 seconds; an unchanged heartbeat is free. Per key there are 600 requests a minute, with batch items counted individually: heartbeating 300 players every 60 seconds spends 5 of the 600.
Endpoint reference: the bridge
Base URL http://127.0.0.1:<port>. Everything but the first three requires Authorization: Bearer <token>.
| Method | Path | Scope | What it does |
|---|---|---|---|
| GET | /mssgs/v1/hello |
none | Is mssgs here, what does it speak, and is anybody signed in. The only route that needs no token, and it says nothing about the player. |
| POST | /mssgs/v1/authorize |
none | Ask the player for permission. Raises a dialog in the app and returns a request_id to poll. |
| GET | /mssgs/v1/authorize/:request_id |
none | pending, approved (with the token), denied or expired. |
| GET | /mssgs/v1/me |
identity |
The signed-in player. Adds is_staff / is_moderator only with the staff scope. |
| GET | /mssgs/v1/membership |
membership.query |
Membership of the server_guid values you pass (up to 10, repeated or comma-separated). |
| GET | /mssgs/v1/servers |
servers.list |
Every community the player is in, with their roles. Direct messages are never included. |
| PUT | /mssgs/v1/activity |
presence.write |
Publish the "Playing …" block. Returns the TTL and how often to heartbeat. |
| POST | /mssgs/v1/activity/heartbeat |
presence.write |
Keep the published activity alive without re-sending it. |
| DELETE | /mssgs/v1/activity |
presence.write |
Clear it immediately, for a clean shutdown. |
| GET | /mssgs/v1/events |
presence.write |
Join hand-offs aimed at your game. Poll with ?since=<cursor>. |
| GET | /mssgs/v1/session |
none | What this token holds: game_id, granted scopes, whether anyone is signed in. |
| DELETE | /mssgs/v1/session |
none | Hand the permission back. Same effect as the player revoking it in Settings. |
Endpoint reference: linked backends
Base URL https://ams1-gateway.mss.gs. Every route requires Authorization: Bearer <backend key> and the activity.write scope on your registration; answers go out with Cache-Control: no-store. Call these from your server, never from the game client.
| Method | Path | What it does |
|---|---|---|
| POST | /game-sdk/v1/link/start |
Start a link for one of your players ({ player_ref, player_name? }). Returns link_code, device_code, qr_url, deep_link, expires_in and interval. |
| POST | /game-sdk/v1/link/poll |
{ device_code } → pending, denied, expired, or linked with link_guid and user. |
| DELETE | /game-sdk/v1/links/{link_guid} |
End a link from your side. The player can do the same from Settings. |
| PUT | /game-sdk/v1/links/{link_guid}/activity |
Publish the "Playing …" block for one player; { "activity": null } clears it. |
| POST | /game-sdk/v1/activity/batch |
The same, for up to 100 players in one call. Each item answers on its own. |
Error codes
Errors come back as {"error":"CODE","message":"…"} with a matching HTTP status.
| Code | Meaning |
|---|---|
401 UNAUTHORIZED | Missing or unknown token; authorize first. |
403 MISSING_SCOPE | The player did not grant that permission. They may have unticked it. |
403 ORIGIN_NOT_ALLOWED | The request carried a browser Origin. See "Native games only" below. |
409 NOT_SIGNED_IN | mssgs is running but nobody is signed in. |
429 RATE_LIMITED | More than 120 requests in a minute from one game. |
400 INVALID_GAME_ID | game_id must be letters, digits, dot, dash or underscore. |
400 TOO_MANY_GUIDS | At most 10 server_guid values per membership call. |
Linked backends
The backend routes use the same shape. Inside a batch the status comes back per item in results, so one ended link never fails the whole call.
| Code | Meaning |
|---|---|
401 INVALID_BACKEND_KEY | Unknown key, or one that was rotated out more than 24 hours ago. |
403 SCOPE_NOT_GRANTED | Your registration does not hold the scope that route needs. |
400 INVALID_PAYLOAD | Malformed body, more than 100 batch items, or a join.url whose host is not one of your registered backends. |
400 INVALID_ACTIVITY | No usable name left after normalisation. |
410 LINK_REVOKED | The link ended, on either side. Drop it and offer "Connect mssgs" again. |
429 SLOW_DOWN | You polled link/poll faster than interval. |
429 RATE_LIMITED | A change to one link within 2 seconds of the last, or more than 600 requests in a minute on your key. |
503 LINK_STORE_UNAVAILABLE | Temporary on our side. Retry on your next heartbeat. |
Security
Native games only
Requests carrying a web page Origin are refused with 403 ORIGIN_NOT_ALLOWED. Any web page being able to detect that you run mssgs and raise a permission dialog is a fingerprinting and phishing surface, not a feature. A native game sends no Origin at all, so it is unaffected, and a game's own embedded browser is allowed by name, see FiveM. If you are building a browser or phone game, you do not talk to the bridge: your own backend publishes for linked players, see Browser and phone games.
What the player keeps hold of
- The player can switch the bridge off in Settings → Game Activity, after which no game can see mssgs at all.
- Every approved game is listed there with exactly the permissions it holds, when it was last active, and a Remove button. Removing is immediate: the token dies at once.
- A linked browser or phone game is listed under Linked games with a Disconnect button. Disconnecting is immediate too: that backend's next publish gets a
410. - The bridge listens on 127.0.0.1 only, never on the network.
- Direct messages are never disclosed, not even with servers.list.
- There is a 120-requests-per-minute budget per game.
Being a good citizen
- Ask for scopes when you need them, not all at once on first launch.
- Work without mssgs: the player does not have to have it.
- Clear your status when play stops rather than waiting for the TTL.
- Treat a refused scope as a normal outcome, not an error.
Questions?
Building something with the Game SDK and stuck? Get in touch on the contact page.