Developer API

Real-time events

Connect to the WebSocket, authorise your channel and receive 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

  1. Call GET /api/v1/me for websocket.url, websocket.channel and websocket.auth_url.

  2. Open the WebSocket at websocket.url. The server greets you with your socket_id:

    {
        "event": "pusher:connection_established",
        "data": "{\"socket_id\":\"123456.7890123\",\"activity_timeout\":30}"
    }
    
  3. Have the subscription signed. POST the socket_id to auth_url with 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" }
    
  4. Subscribe with that signature:

    {
        "event": "pusher:subscribe",
        "data": {
            "channel": "private-creator.12.events",
            "auth": "<key>:<signature>"
        }
    }
    
  5. 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

  • data is a JSON string inside the JSON message. Decode the message, then decode data again.
  • Keep the connection alive. If you have sent nothing for activity_timeout seconds, send {"event":"pusher:ping","data":{}}. The server answers with pusher: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
}
  • fragments put text back together piece by piece: text, emote, mention and cheermote (prefix, bits, tier). Draw emotes from emote.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 one text fragment; GET /stream/chat has it with every emote, under the same id. In rare cases (hundreds of emoji) the message is only in that backlog.
  • roles is any of broadcaster, moderator, vip, subscriber and staff, and flags repeats the first four for convenience. first_message is a chatter's first message in the channel, as Twitch marks it.
  • reply is message_id, author and text of the message answered, or null. shared_from is the channel a message came from during a shared chat session, or null.

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.