Developer API
Real-time events
Events arrive over a WebSocket, a second or two after they happen. The server speaks the Pusher Channels protocol, so a Pusher client library works. The protocol is also small enough to speak by hand from any language. This page shows how.
The flow
-
Call
GET /api/v1/meforwebsocket.url,websocket.channelandwebsocket.auth_url. -
Open the WebSocket at
websocket.url. The server greets you with yoursocket_id:{ "event": "pusher:connection_established", "data": "{\"socket_id\":\"123456.7890123\",\"activity_timeout\":30}" } -
Have the subscription signed.
POSTthesocket_idtoauth_urlwith your token:curl -X POST https://gleem.gg/api/v1/socket/auth \ -H "Authorization: Bearer gleem_pat_…" \ -H "Content-Type: application/json" \ -d '{"socket_id":"123456.7890123"}'{ "auth": "<key>:<signature>", "channel_name": "private-creator.12.events" } -
Subscribe with that signature:
{ "event": "pusher:subscribe", "data": { "channel": "private-creator.12.events", "auth": "<key>:<signature>" } } -
The server confirms with
pusher_internal:subscription_succeeded. From then on, every event arrives as one message:{ "event": "tip.created", "channel": "private-creator.12.events", "data": "{\"id\":\"01k6a9…\",\"type\":\"tip.created\",…}" }
The message's event is the event type. Its data is the event envelope.
Things your client must handle
datais a JSON string inside the JSON message. Decode the message, then decodedataagain.- Keep the connection alive. If you have sent nothing for
activity_timeoutseconds, send{"event":"pusher:ping","data":{}}. The server answers withpusher:pong. A connection that stays silent is closed. - Reconnect when the connection drops, with a growing delay (1 s, 2 s, 5 s, 10 s, capped at 30 s). A new connection has a new
socket_id, so authorise the channel again. - Ignore duplicates. Every event has a unique
id. Remember the last few hundred and skip any you have already handled. - Ignore types you do not know. New event types are added over time; they are not a breaking change.
- Events sent while you are disconnected are not replayed. A tool that was offline does not receive what happened in the meantime.
A token may only subscribe to its own creator's channel, to the telemetry and network channels of its own IRL devices, and to its Stream Manager channel (all below). Asking to sign any other channel is refused with 403.
IRL device telemetry
With irl:read, a token can also follow one of the creator's IRL devices live: every heartbeat, as it arrives, about every 5 seconds. Take the channel from telemetry_socket in GET /irl/devices/{uuid}, private-irl-device.<uuid>.telemetry, and sign it the same way, naming it in channel_name:
{
"socket_id": "123456.7890123",
"channel_name": "private-irl-device.9d3c2f4e-6b1a-4c8e-9f2d-1a2b3c4d5e6f.telemetry"
}
Each heartbeat arrives as a telemetry event, whose data holds uuid, online, last_seen_at and telemetry: the heartbeat as the device sent it, without its network addresses (see below), and a phone's phone object. Ingest's view of a running session arrives as ingest.stats (uuid, sid, region, rx_bps, publishing and links, each with link_id, rx_bps, rx_bytes, rx_packets, lost_packets, registered_at and last_packet_at), and what became of a command as command.updated. A heartbeat too large to push loses its interfaces, temperatures, settings and update report first. Nothing arrives while the device is offline, so treat about 15 seconds of silence as offline.
Signing a telemetry channel needs Gleem IRL on the account, and the device has to be the creator's. That includes the tokens Gleem hands to a rented OBS machine: the channel carries no addresses.
Network addresses
The telemetry channel never carries the device's network addresses: in telemetry, each of interfaces keeps only name, kind and state, and stream.link.links only the link's health (link_id, iface, state, rtt_ms, loss_pct, tx_bps, window, in_flight, sent_packets, lost_packets, queue_ms, congested, delivered_bps); in ingest.stats, the links lose remote. Those addresses locate the streamer's modems.
A token that also holds the sensitive irl:network scope can sign the device's network channel instead, private-irl-device.<uuid>.network (telemetry_socket.network_channel). It carries the same telemetry, ingest.stats and command.updated events in full: interfaces[].addresses, the IP addresses the device reported for each interface, and remote, the address and port each link reaches ingest from. Subscribe to one of the two channels, not both, or every event arrives twice.
Signing the network channel needs everything the telemetry channel needs and irl:network; without it the answer is 403 insufficient_scope. Tokens Gleem hands to a rented OBS machine cannot sign it, whatever their scopes: that machine runs on someone else's hardware.
Stream Manager
With stream:read, a token can follow the creator's stream itself: chat, moderation and activity from Twitch, and Gems, as they happen. This is what the Gleem IRL app's Stream Manager shows. The channel is private-creator.<id>.stream, named in socket.channel by GET /stream; sign it the same way, naming it in channel_name. Like the rest of the Stream Manager it is part of the Gleem IRL beta: without Gleem IRL on the account, signing it answers 403 with the code feature_unavailable.
Every event is one envelope, whichever platform it came from:
{
"id": "01k6b2x7r3m9q0w8e5t4y6u2i1",
"type": "chat.message",
"platform": "twitch",
"occurred_at": "2026-10-02T10:15:30Z",
"deleted": false,
"data": {}
}
The Pusher event name is type. platform is twitch, or gleem for Gems; more platforms follow in the same shapes. A user is always {"id", "login", "display_name"} with the platform's own ids. Chat messages and activity are kept for a day and can be read back with GET /stream/chat and GET /stream/activity, with the same id.
type |
data |
|---|---|
chat.message |
A chat message. See below. |
chat.message.deleted |
message_id: a moderator removed that message. |
chat.user.cleared |
user: they were timed out or banned, and their messages are gone. |
activity.follow |
user. |
activity.subscription |
user, tier (1, 2 or 3), is_gift, is_resub, months, streak_months, duration_months (each null when not known), message (text and fragments, or null). |
activity.gift |
user (null when anonymous), tier, count (subs in this gift), total (all the gifter has given, or null). |
activity.raid |
user (the raider), viewers. |
activity.redemption |
user, redemption_id, reward (id, title, cost, prompt), input (or null), status (unfulfilled, fulfilled or canceled), can_manage. |
activity.redemption.updated |
redemption_id, reward_id, status: a redemption was fulfilled or refunded. Not kept; the card's status changes instead. |
activity.gems |
user (the viewer's Gleem member id, login, display_name, twitch_id), tip_id, gems, message, message_filtered, gif, test. |
stream.online |
started_at. Not kept. |
stream.offline |
Empty. Not kept. |
A chat.message:
{
"message_id": "cc106a89-1814-919d-454c-f4f2f970aae7",
"author": {
"id": "1001",
"login": "viewer",
"display_name": "Viewer",
"color": "#FF0000",
"badges": [
{
"set": "moderator",
"id": "1",
"info": null,
"image_url": "https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/2"
}
],
"roles": ["moderator"]
},
"text": "hi Kappa @Streamer",
"fragments": [
{ "type": "text", "text": "hi " },
{
"type": "emote",
"text": "Kappa",
"emote": {
"id": "25",
"provider": "twitch",
"url": "https://static-cdn.jtvnw.net/emoticons/v2/25/static/dark/2.0",
"animated": false
}
},
{ "type": "text", "text": " " },
{
"type": "mention",
"text": "@Streamer",
"mention": {
"id": "4711",
"login": "streamer",
"display_name": "Streamer"
}
}
],
"flags": {
"first_message": true,
"highlighted": false,
"broadcaster": false,
"moderator": true,
"vip": false,
"subscriber": false
},
"bits": 0,
"reply": null,
"reward_id": null,
"shared_from": null
}
fragmentsputtextback together piece by piece:text,emote,mentionandcheermote(prefix,bits,tier). Draw emotes fromemote.url; emotes from other providers (7TV, BTTV, FFZ) are not resolved here and arrive as text. A message too large to push live (long emote spam) arrives on the channel with its fragments collapsed into onetextfragment;GET /stream/chathas it with every emote, under the sameid. In rare cases (hundreds of emoji) the message is only in that backlog.rolesis any ofbroadcaster,moderator,vip,subscriberandstaff, andflagsrepeats the first four for convenience.first_messageis a chatter's first message in the channel, as Twitch marks it.replyismessage_id,authorandtextof the message answered, ornull.shared_fromis the channel a message came from during a shared chat session, ornull.
The channel tells each change once, but a chat.message.deleted or chat.user.cleared can reach the app before the platform confirms it, when the app made the change itself. Treat both as idempotent, and skip envelopes whose id you have seen.
Example: JavaScript
Works in a browser and in Node.js 22 or newer, with no library:
const token = 'gleem_pat_…';
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
const me = await (
await fetch('https://gleem.gg/api/v1/me', { headers })
).json();
const ws = new WebSocket(me.websocket.url);
ws.onmessage = async ({ data }) => {
const message = JSON.parse(data);
if (message.event === 'pusher:connection_established') {
const { socket_id } = JSON.parse(message.data);
const auth = await (
await fetch(me.websocket.auth_url, {
method: 'POST',
headers,
body: JSON.stringify({ socket_id }),
})
).json();
ws.send(
JSON.stringify({
event: 'pusher:subscribe',
data: { channel: auth.channel_name, auth: auth.auth },
}),
);
} else if (
message.channel === me.websocket.channel &&
!message.event.startsWith('pusher')
) {
const event = JSON.parse(message.data);
console.log(event.type, event.data);
}
};
setInterval(
() => ws.send(JSON.stringify({ event: 'pusher:ping', data: {} })),
25_000,
);
Example: C#
Uses only .NET's built-in ClientWebSocket and System.Text.Json, so it also fits a Streamer.bot C# action:
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
var token = "gleem_pat_…";
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new("Bearer", token);
var me = await http.GetFromJsonAsync<JsonElement>("https://gleem.gg/api/v1/me");
var socket = me.GetProperty("websocket");
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri(socket.GetProperty("url").GetString()!), CancellationToken.None);
var buffer = new byte[64 * 1024];
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
var message = JsonDocument.Parse(Encoding.UTF8.GetString(buffer, 0, result.Count)).RootElement;
var name = message.GetProperty("event").GetString();
if (name == "pusher:connection_established")
{
var socketId = JsonDocument.Parse(message.GetProperty("data").GetString()!)
.RootElement.GetProperty("socket_id").GetString();
var response = await http.PostAsJsonAsync(socket.GetProperty("auth_url").GetString(), new { socket_id = socketId });
var auth = await response.Content.ReadFromJsonAsync<JsonElement>();
var subscribe = JsonSerializer.Serialize(new
{
@event = "pusher:subscribe",
data = new { channel = auth.GetProperty("channel_name").GetString(), auth = auth.GetProperty("auth").GetString() },
});
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(subscribe)), WebSocketMessageType.Text, true, CancellationToken.None);
}
else if (name is not null && !name.StartsWith("pusher"))
{
var envelope = JsonDocument.Parse(message.GetProperty("data").GetString()!).RootElement;
Console.WriteLine($"{envelope.GetProperty("type")}: {envelope.GetProperty("data")}");
}
}
The example leaves out the keep-alive ping, reconnecting, and messages split across several frames (check result.EndOfMessage), all of which a real tool needs.
Using a Pusher library
Point the library at the host and path from websocket.url, give it the key from the URL, and use auth_url as its channel authorisation endpoint with your token in the Authorization header. Subscribe to websocket.channel and bind the event types you want. With Laravel Echo, prefix custom event names with a dot, such as .tip.created.