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