Skip to main content

mssgs Game SDK

Let your game find the mssgs client on the same machine, 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.

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.

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.

GET 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.

Response
{
  "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.

Asking permission

Everything except /hello needs a token, and a token only exists after the player approved your game in a dialog inside the app. Ask only for the scopes you actually use: the player sees each one separately, explained, and can untick them individually.

1. Request permission
curl -X POST http://127.0.0.1:7440/mssgs/v1/authorize \
  -H "Content-Type: application/json" \
  -d '{
    "game_id": "com.acme.spacegame",
    "name": "Space Raiders",
    "scopes": ["identity", "membership.query", "presence.write"]
  }'

You get back {"status":"pending","request_id":"…","poll_after_ms":1000} and the player sees the dialog. Then poll until they answer (the request expires after 3 minutes):

2. Poll for the answer
curl http://127.0.0.1:7440/mssgs/v1/authorize/<request_id>

# {"status":"approved","token":"…","scopes":["identity","presence.write"],"game_id":"com.acme.spacegame"}

Always check what you actually got

The scopes in the answer may be shorter than what you asked for — the player is free to untick individual ones. In the example above, membership.query was refused. Branch on what the response says, not on what you requested, or you will meet a 403 MISSING_SCOPE you did not plan for.

Store the token and send it as Authorization: Bearer <token>. It survives restarts, so a player approves your game once rather than every session. If you later re-authorize with scopes that were already granted, you get the same token straight back with no dialog.

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.

One community
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.

PUT /mssgs/v1/activity
{
  "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:

Response
{ "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 partya squad, crew or group
server4/100 playersa game server (FiveM, a community server)
lobby4/100 playersa lobby before the match starts
match4/100 playersa 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:

GET /mssgs/v1/events
{
  "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.

client.lua — ask the NUI to publish
-- 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)
nui.js
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.

Endpoint reference

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.

Error codes

Errors come back as {"error":"CODE","message":"…"} with a matching HTTP status.

Code Meaning
401 UNAUTHORIZEDMissing or unknown token — authorize first.
403 MISSING_SCOPEThe player did not grant that permission. They may have unticked it.
403 ORIGIN_NOT_ALLOWEDThe request carried a browser Origin. See "Native games only" below.
409 NOT_SIGNED_INmssgs is running but nobody is signed in.
429 RATE_LIMITEDMore than 120 requests in a minute from one game.
400 INVALID_GAME_IDgame_id must be letters, digits, dot, dash or underscore.
400 TOO_MANY_GUIDSAt most 10 server_guid values per membership call.

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 game, bring players in through a join.url rather than talking to the bridge directly.

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.
  • 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.