Developer API
Webhooks
Webhooks POST each event to a URL of yours. They suit server-side integrations: a Discord bot, an automation service, your own backend. A tool running on a streaming PC usually cannot receive requests from the internet, so it should use the WebSocket instead. Both carry exactly the same events.
Adding an endpoint
On the Developer page of your dashboard, add the URL and choose which event types it receives, or all of them, including types added later. You get the endpoint's signing secret, starting whsec_. You can show it again or replace it on the same page. A new secret takes effect immediately, so update your receiver first.
URLs must use https:// and point at a public address. Addresses in private, loopback and link-local ranges are refused, both when you save the endpoint and on every delivery. Redirects are not followed. You can have up to five endpoints.
What arrives
A POST with the event envelope as its JSON body, and these headers:
| Header | Contains |
|---|---|
Gleem-Signature |
t=<unix time>,v1=<signature>. Check it before trusting the body. |
Gleem-Event-Id |
The event's id. Use it to ignore a delivery you already handled. |
Gleem-Event-Type |
The event's type. |
Gleem-Delivery-Attempt |
1 for the first try, higher for retries and redeliveries. |
User-Agent |
Gleem-Webhooks/1.0 |
Answer with any 2xx status within 10 seconds. Do the slow work afterwards, in the background.
Verifying the signature
The signature is an HMAC-SHA256, keyed with your secret, of the timestamp, a full stop, and the raw request body. Compute it and compare it with v1 using a constant-time comparison. Reject a timestamp more than five minutes old, so a captured request cannot be replayed later.
Use the body exactly as it arrived. Parsing the JSON and encoding it again changes the bytes and breaks the signature.
PHP
function gleemSignatureIsValid(string $header, string $body, string $secret): bool
{
parse_str(str_replace(',', '&', $header), $parts);
if (! isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > 300) {
return false;
}
return hash_equals(hash_hmac('sha256', $parts['t'].'.'.$body, $secret), $parts['v1']);
}
$body = file_get_contents('php://input');
if (! gleemSignatureIsValid($_SERVER['HTTP_GLEEM_SIGNATURE'] ?? '', $body, getenv('GLEEM_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function gleemSignatureIsValid(header, rawBody, secret) {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=')),
);
if (
!parts.t ||
!parts.v1 ||
Math.abs(Date.now() / 1000 - Number(parts.t)) > 300
)
return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return (
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
}
// With Express, read the raw body: app.post('/gleem', express.raw({ type: 'application/json' }), …)
C#
using System.Security.Cryptography;
using System.Text;
static bool GleemSignatureIsValid(string header, string rawBody, string secret)
{
var parts = header.Split(',').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => p[1]);
if (!parts.TryGetValue("t", out var t) || !parts.TryGetValue("v1", out var v1)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}"))).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1));
}
Retries and switching off
Anything but a 2xx within 10 seconds counts as a failure, including a redirect. A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then given up.
After 15 failed attempts in a row, across all events, the endpoint is switched off and you get an email. Fix the receiver, then switch the endpoint back on from the Developer page. Any successful delivery resets the count.
Retries mean an event can arrive more than once, and not always in order. Use Gleem-Event-Id to skip duplicates, and occurred_at to order events if that matters.
The delivery log
For each endpoint, the Developer page shows the last deliveries: the event, the attempt, the status your server answered, the start of its response, and how long it took. Redeliver sends that event again right away, as the next attempt. Events are kept for 30 days, so anything in that window can be redelivered.
To try your receiver without waiting for a real tip, use Send test event on the same page. Test events go to every endpoint subscribed to their type, with "test": true in the envelope.
App webhooks
An OAuth app can register webhooks for a creator it is connected to, so the creator does not have to set them up by hand. It needs the webhooks:write scope, and uses that creator's OAuth access token.
POST https://gleem.gg/api/v1/webhooks
Authorization: Bearer gleem_oat_…
Content-Type: application/json
{"url": "https://hooks.example.com/gleem", "event_types": ["tip.created", "gems.exchanged"]}
{
"id": 42,
"url": "https://hooks.example.com/gleem",
"event_types": ["tip.created", "gems.exchanged"],
"is_active": true,
"disabled_at": null,
"created_at": "2026-10-01T12:00:00Z",
"secret": "whsec_…"
}
The signing secret is in this response only. GET /api/v1/webhooks lists your app's webhooks for the creator, without secrets, and DELETE /api/v1/webhooks/{id} removes one. You never see the creator's own webhooks or another app's.
An app can only subscribe to events its scopes cover. Each event type needs one scope:
| Scope | Event types |
|---|---|
tips:read |
tip.created, gems.exchanged |
queue:read |
treat.redeemed, queue.entry.updated, queue.opened, queue.closed |
members:read |
membership.started, membership.renewed, membership.tier_changed, membership.cancelled, post.unlocked |
events:read |
follow.created, tickets.granted, stream.online, stream.offline |
Asking for a type your app was not granted answers 422. Leave event_types out, or send null, for every type your scopes cover, including types added later.
Deliveries use the same envelope, signature, retries and switching-off as above. After 15 failures in a row, Gleem switches the webhook off and emails both the creator and your app's support contact. An app can have up to five webhooks per creator, on top of the creator's own.
The creator sees your webhooks on their Developer page, labelled with your app's name, and can switch them off or delete them. App webhooks belong to the grant: they are deleted when the creator disconnects your app, when its last session ends, or when Gleem disables the app.