PlasmaStream API
Last updated September 14, 2026
The API lets a program of yours read your channel and act on it: your points leaderboard on your own website, a Stream Deck button that hands out points, an alert on stream when something happens somewhere PlasmaStream does not connect to. Everything a key does happens to the channel it belongs to, and only that channel.
Keys
Make a key on the API page of your dashboard. Each key is allowed to do only what you switch on when you make it:
| Scope | On the dashboard | What it allows |
|---|---|---|
read | Read | See your channel, a viewer's points and watch time, and your leaderboards. |
points:write | Change points | Give viewers points, or take points away. |
alerts:write | Show alerts | Put an alert on your overlay, as if it had just happened. |
A key is shown once, when you make it. Only a hash of it is kept, so it cannot be shown again: if you lose one, revoke it and make another. A channel can have 3 keys, or 10 with Premium.
Treat a key like a password. Keep it in a program on a computer you control, and never put one in a web page, where anybody can read it. The API does not answer scripts running in a browser on another site, so a key in a page would not work anyway.
Making a request
Send your key in an Authorization header on every request. Every answer is JSON.
curl https://plasmastream.live/api/v1/channel \
-H "Authorization: Bearer psk_your_key_here"A request with a body sends it as JSON:
curl -X POST https://plasmastream.live/api/v1/viewers/houtenxd/points \
-H "Authorization: Bearer psk_your_key_here" \
-H "Content-Type: application/json" \
-d '{"amount": 250, "reason": "Won the raffle"}'Numbers in a body can be JSON numbers or text, so 250 and "250" both work. A key is never read from the URL, only from the header.
Limits
- Each key can make 60 requests a minute, or 300 when the channel has Premium. Every answer carries
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset, the seconds until the minute starts again. Past the limit the answer is a 429 with aRetry-Afterheader. - Alerts have a tighter limit of their own: a channel can be sent 10 a minute through the API, however many keys it has.
- A request body can be at most 16 KB.
Errors
Anything refused answers with an HTTP status and a body like this:
{
"error": {
"code": "invalid_field",
"field": "amount",
"message": "amount has to be a whole number other than zero: positive to give points, negative to take them away."
}
}Check code in a program: it will not change. message is written for a person and might. field is only there when one field was the problem.
| Code | Status | Meaning |
|---|---|---|
missing_key | 401 | There is no Authorization header. |
invalid_key | 401 | The key is not one, or it has been revoked. |
missing_scope | 403 | The key is not allowed to do that. |
rate_limited | 429 | Too many requests. Wait for Retry-After. |
not_found | 404 | There is no such endpoint, or no such board. |
invalid_json | 400 | The body is not JSON. |
body_too_large | 413 | The body is over 16 KB. |
invalid_field | 422 | One field is wrong, and field says which. |
viewer_not_found | 404 | Nobody with that login has chatted in the channel. |
viewer_excluded | 409 | That viewer is excluded from the channel's economy. |
insufficient_points | 409 | They do not have that many points to take. |
board_off | 409 | That leaderboard is switched off. |
overlay_not_set_up | 409 | The overlay has not been added to OBS yet. |
alert_off | 409 | That alert is switched off in the overlay studio. |
server_error | 500 | Something broke on our side. Try again shortly. |
Endpoints
GET/api/v1/channel
Needs the read scope.
The channel, whether it is live, and its headline numbers. stream is null while it is offline, and followers and subscribers are null until the bot has been able to count them.
{
"login": "houtenxd",
"displayName": "HoutenXD",
"twitchId": "123456789",
"live": true,
"stream": {
"title": "Chill games and chat",
"category": "Just Chatting",
"thumbnailUrl": "https://static-cdn.jtvnw.net/previews-ttv/live_user_houtenxd-1280x720.jpg"
},
"followers": 1520,
"subscribers": 48,
"streams": 212,
"currency": "Points"
}GET/api/v1/viewers/{login}
Needs the read scope.
One viewer, by their Twitch login in any case, with or without an @. Only people who have chatted in the channel are found. watchSeconds counts time in chat while the stream was live.
{
"login": "mr_dino_saur_",
"displayName": "Mr_Dino_Saur_",
"twitchId": "987654321",
"points": 12850,
"watchSeconds": 187200,
"level": 14,
"xp": 9120,
"streak": 6,
"firstSeen": "2026-06-02T19:14:08.000Z",
"lastSeen": "2026-09-13T22:41:50.000Z"
}POST/api/v1/viewers/{login}/points
Needs the points:write scope.
Give a viewer points, or take them away.
| Field | What it is |
|---|---|
amount | A whole number, positive to give and negative to take, at most 1,000,000 either way. |
reason | Optional. Up to 200 characters, kept with the change so you can see later why it happened. |
Taking more than the viewer has is refused with insufficient_points, and nothing changes. Points moved this way show in your channel analytics as coming from the API. The answer is the change and the viewer after it:
{
"change": 250,
"viewer": {
"login": "mr_dino_saur_",
"displayName": "Mr_Dino_Saur_",
"twitchId": "987654321",
"points": 13100,
"watchSeconds": 187200,
"level": 14,
"xp": 9120,
"streak": 6,
"firstSeen": "2026-06-02T19:14:08.000Z",
"lastSeen": "2026-09-13T22:41:50.000Z"
}
}A request that times out may still have gone through. Read the viewer before sending it again, or they may be paid twice.
GET/api/v1/leaderboards
Needs the read scope.
The boards the channel offers, out of points, level, watch and donations. They are the boards on the public leaderboard page, with the same people on them: anybody who asked not to be listed, anybody excluded from the economy, and the streamer are all left off.
{ "boards": ["points", "level", "watch"] }GET/api/v1/leaderboards/{board}?limit=10
Needs the read scope.
One board. limit is how many places to return: 10 when it is left out, and at most 25, or 100 with Premium. The answer says the limit it used. value is points on the points board, a level on the level board, seconds on the watch board and cents on the donations board.
{
"board": "points",
"limit": 3,
"entries": [
{ "rank": 1, "displayName": "IsThisShnub", "twitchId": "555000111", "value": 48210 },
{ "rank": 2, "displayName": "Scooter_02_", "twitchId": "555000222", "value": 36990 },
{ "rank": 3, "displayName": "Mr_Dino_Saur_", "twitchId": "555000333", "value": 12850 }
]
}POST/api/v1/alerts
Needs the alerts:write scope.
Put an alert on the overlay, looking the way that alert is set up in the overlay studio. Only the alert: nothing is said in chat, nobody is paid, and no subathon time is added.
| type | Fields |
|---|---|
follow | user |
subscribe | user, tier |
resub | user, months, streak, tier |
gift_sub | user, count, tier |
raid | user, viewers |
cheer | user, bits |
donation | user, amount, message |
user is the name to show, up to 40 characters. tier is 1, 2 or 3 and 1 when left out. streak is the months when left out. For a donation, amount is in dollars, like 4.99, and message is optional, up to200 characters. Every other field listed is a whole number and required.
curl -X POST https://plasmastream.live/api/v1/alerts \
-H "Authorization: Bearer psk_your_key_here" \
-H "Content-Type: application/json" \
-d '{"type": "raid", "user": "Scooter_02_", "viewers": 42}'The answer is a 202 with {"queued": true, "type": "raid"}. The overlay plays the alert within a second if OBS has it open. An overlay that is not open drops alerts older than two minutes rather than playing a backlog late.
Webhooks
A webhook is the other direction. Add one on the API page of your dashboard with a URL and the events you want, and PlasmaStream sends a POST to that URL the moment one happens. A channel can have 2 webhooks, or 10 with Premium.
- The URL has to be https, on port 443 or 8443, somewhere on the public internet.
- Answer with any 2xx status within 10 seconds. Anything else, including a redirect, which is not followed, counts as a failure.
- The body of your answer is ignored, so an empty 204 is fine.
What arrives
| Header | What it holds |
|---|---|
Content-Type | application/json |
PlasmaStream-Event | The event, like raid. |
PlasmaStream-Delivery | This delivery's id, the same every time it is tried. |
PlasmaStream-Signature | t=, the time it was signed, and v1=, the signature. See below. |
{
"id": "evt_4b9c1f0e2d8a4c67b3e5a1d0f9c2b7e8",
"type": "raid",
"createdAt": "2026-09-14T20:03:11.000Z",
"channel": { "login": "houtenxd", "twitchId": "123456789" },
"data": {
"user": { "name": "Scooter_02_", "login": "scooter_02_", "twitchId": "555000222" },
"viewers": 42
}
}id belongs to the event, and is the same on every webhook it went to. user is who it was about; login and twitchId are null when PlasmaStream does not know, such as an anonymous gift or a donation made signed out.
Events
| Event | When | data |
|---|---|---|
follow | Somebody follows, the first time they ever do. | user |
subscribe | A new subscription, paid or Prime. | user, tier |
resub | A subscriber renews and shares it in chat. | user, tier, months, streak |
gift_sub | Somebody gifts subs. A gift of twenty is one event. | user, tier, count |
raid | Another streamer raids with their viewers. | user, viewers |
cheer | Somebody cheers bits in chat. | user, bits |
donation | A donation through your page is paid, or approved if you check them first. | user, amount, amountCents, message |
stream_online | Your Twitch stream goes live. | streamId, title, category |
stream_offline | Your Twitch stream ends. | streamId |
ping | You press Send a test on the dashboard. | message |
tier, months, streak, count, viewers, bits and amountCents are numbers. amount is the donation as shown, like $5.00.
Checking the signature
Anybody can send a POST to your URL, so check each delivery came from PlasmaStream before acting on it. The signature is an HMAC-SHA256, keyed with your signing secret (all of it, whsec_ included), of the t value, a full stop, and the body exactly as it arrived. Parsing the JSON and turning it back into text changes the bytes, and the signature will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as it arrived, before any JSON parsing.
export function isFromPlasmaStream(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(signatureHeader.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
// Refuse anything signed more than five minutes from now: a replay.
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false;
}
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}import hashlib, hmac, time
# raw_body: the request body exactly as it arrived, as bytes.
def is_from_plasmastream(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(part.split("=", 1) for part in signature_header.split(","))
timestamp = int(parts.get("t", "0"))
# Refuse anything signed more than five minutes from now: a replay.
if abs(time.time() - timestamp) > 300:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries, duplicates and switching off
- A delivery that fails is tried again after a minute, 5 minutes, 30 minutes, 2 hours and 6 hours: 6 attempts in all.
- The same delivery can arrive twice, for instance when your server handled it but its answer never reached us. Keep the
PlasmaStream-Deliveryids you have seen and ignore a repeat. - Deliveries are not guaranteed to arrive in order once one has been retried.
- A webhook that fails 50 times in a row, with nothing delivered for a day, is switched off. The dashboard says why, and it can be turned back on there.
- At most 500 deliveries wait for one webhook. Past that, new events for it are dropped until it catches up.
Changes
Everything here is version 1, and it will grow: new endpoints, new events, and new fields in answers and deliveries, so read the fields you need and ignore the rest. Nothing in version 1 will be renamed or removed. A change like that would be a version 2, running alongside it.