Aller au contenu principal
Developers

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.

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.

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.

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:

POST /game-sdk/v1/link/start
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:

POST /game-sdk/v1/link/poll
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:

PUT /game-sdk/v1/links/{link_guid}/activity
{
  "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=…" }
  }
}
Responses
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:

POST /game-sdk/v1/activity/batch
{ "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 stamped via: "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 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.

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_KEYUnknown key, or one that was rotated out more than 24 hours ago.
403 SCOPE_NOT_GRANTEDYour registration does not hold the scope that route needs.
400 INVALID_PAYLOADMalformed body, more than 100 batch items, or a join.url whose host is not one of your registered backends.
400 INVALID_ACTIVITYNo usable name left after normalisation.
410 LINK_REVOKEDThe link ended, on either side. Drop it and offer "Connect mssgs" again.
429 SLOW_DOWNYou polled link/poll faster than interval.
429 RATE_LIMITEDA change to one link within 2 seconds of the last, or more than 600 requests in a minute on your key.
503 LINK_STORE_UNAVAILABLETemporary 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.