Developers
Points API
Read and move loyalty points in your own channel from your own scripts — so a game you built in Streamer.bot, an overlay, or anything else that can make an HTTP request can pay your viewers.
https://streamfirefly.comOverview
The API is scoped to a single channel: a key resolves to exactly one StreamFirefly account, and every request reads and writes only that channel's points. There is no endpoint that takes another streamer's id, and none that fires effects.
Every move it makes is recorded in your points ledger with the key that made it, so you can always see what an integration did — and undo it.
Authentication
Create a key in Dashboard → Settings → Points API keys. It looks like this, and is shown exactly once:
sf_live_xxxxxxxxxxxx_your-43-character-secretSend it as a bearer token on every request. Each key carries scopes — points:read for the two GET endpoints, points:write for the two points POST endpoints — so a key that only reads balances cannot move any. firesale:write starts and stops a Firesale and drops:write triggers a Drop; both require an active Pro subscription. Every other scope is free on every tier.
Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-hereKeys are independently revocable and take effect immediately. Up to 10 can be active at once, which is what lets you rotate without downtime: create the new one, move your scripts over, revoke the old one.
Responses
Every response is a JSON object with an ok boolean. Success carries a data object; failure carries a stable machine-readable error code plus a human message. Branch on error — the wording of message may change.
// success
{ "ok": true, "data": { ... } }
// failure
{ "ok": false, "error": "unknown_target", "message": "No viewer found with a balance in this channel." }Errors
| Code | HTTP | Meaning |
|---|---|---|
| invalid_request | 400 | Something in the request is malformed — read message for which field. |
| unauthorized | 401 | Missing, malformed, unknown or revoked key. Every credential failure returns this same response. |
| forbidden | 403 | The key is valid but lacks the scope this endpoint requires. |
| unknown_target | 404 | No viewer matched in this channel. Also returned for a viewer we have never seen. |
| conflict | 409 | That idempotency_key was already used for a different operation. |
| rate_limited | 429 | Too many requests. Honour the Retry-After header. |
| unavailable | 503 | Twitch could not be reached to verify a new viewer. Honour the Retry-After header. |
| server_error | 500 | Something failed on our side. Safe to retry with the same idempotency_key. |
Rate limits
60 requests per minute on the free plan and 300 per minute on Pro, plus 600 per minute per IP address. Exceeding either returns 429 with a Retry-After header in seconds.
The plan limit is counted per channel, not per key: your keys share one budget, so minting more of them does not buy more throughput. Keys are for separating and revoking integrations independently, not for scaling.
Creating a viewer we have never seen costs a lookup against Twitch, and that lookup is shared by every channel on StreamFirefly. It carries its own separate budget, so a request using create: true can return 429 even when your key is well inside its own limit. Retry, or send the request without create.
There is no daily cap on how many points a key may award. Points are yours to mint, and an invisible ceiling that silently stopped a payout script mid-stream would be worse than the thing it guards against.
Identifying a viewer
Every endpoint that names a viewer takes a platform plus either a user_id or a username.
user_idis the platform's own id — numeric on Twitch and Kick, theUC…channel id on YouTube. It is authoritative and never ambiguous.usernamematches the viewer's display name as we last saw it in your chat, case-insensitively. This is the practical choice from Streamer.bot, where%sfUsername%is supplied for you.
The one case username misses is a viewer who changed their name between their last activity in your channel and your request; it corrects itself the next time they appear in chat or redeem something.
On the two write endpoints, user_id, username and display_name are each limited to 100 characters — comfortably above every real value, since Twitch ids are around ten digits and YouTube channel ids are 24.
Viewers we have never seen
A write to an unknown viewer is refused by default rather than invented. Pass create: true to add them:
- On Twitch the identity is verified with Twitch before anything is created, so a typo is a
404rather than a viewer who can never spend what you paid them. 🔴 Preferuser_idfor repeat writes. A viewer is matched by display name, so someone whose Twitch display name differs from their login — a localised name, for instance — is re-verified with Twitch on every write byusername, which draws on the budgets below each time. Writes byuser_idnever do that once the viewer exists. - On YouTube and Kick there is no username lookup available to us, so
create: trueis taken on trust. Auser_idtypo there creates a balance nobody can reach — double-check ids on those platforms, and note that creating from ausernameis refused outright. createdoes not apply to/remove: a viewer with no balance in your channel is refused with404 unknown_target.- If Twitch itself cannot be reached, you get
503with aRetry-Afterrather than a404— the viewer may well be real. Back off on it: retrying immediately consumes the budgets below without ever reaching Twitch. - Adding a viewer draws on a separate per-channel budget, and on Twitch also on one shared by every channel, so
create: truecan return429even when you are inside your plan limit. It applies on every platform, but only to viewers we have never seen — once a viewer exists, writes to them never touch it.
Idempotency
Both write endpoints accept an optional idempotency_key — any text up to 200 characters, scoped to your channel. If a request with the same key has already been applied, the original result is returned with idempotent: true and nothing moves a second time. That makes a retry after a timeout safe.
Reusing a key for a different operation — a different viewer, or add versus remove — returns 409 conflict instead of silently replaying the first one. Use a new key per logical award: an event id, or something like bossfight-2026-08-23-ava.
Endpoints
One viewer's balance in your channel, plus what you call your points.
| Query parameter | Type | Notes | |
|---|---|---|---|
| platform | string | required | twitch, youtube or kick. |
| user_id | string | one of | The platform id. display_name comes back null on this path. |
| username | string | one of | Display name, case-insensitive. |
curl "https://streamfirefly.com/api/v1/points/balance?platform=twitch&username=ava" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"{
"ok": true,
"data": {
"platform": "twitch",
"user_id": "123456789",
"display_name": "Ava",
"balance": 4200,
"points_name": "Embers"
}
}The highest balances in your channel, ranked. Viewers with no points are omitted.
| Query parameter | Type | Notes | |
|---|---|---|---|
| limit | integer | optional | Between 1 and 100. Defaults to 10. |
curl "https://streamfirefly.com/api/v1/points/leaderboard?limit=3" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"{
"ok": true,
"data": {
"entries": [
{ "rank": 1, "display_name": "Ava", "balance": 12400 },
{ "rank": 2, "display_name": "Boyd", "balance": 9880 },
{ "rank": 3, "display_name": "Cass", "balance": 7310 }
],
"points_name": "Embers"
}
}Awards points to a viewer. The new balance comes back, so you can announce it without a second request.
| Body field | Type | Notes | |
|---|---|---|---|
| platform | string | required | twitch, youtube or kick. |
| user_id | string | one of | The platform id. |
| username | string | one of | Display name, case-insensitive. |
| amount | integer | required | A whole number from 1 to 10,000,000. |
| create | boolean | optional | Add a viewer we have not seen. Verified on Twitch; taken on trust elsewhere. |
| display_name | string | optional | A name to store if the viewer is created. Ignored on Twitch, where the real one is fetched. |
| idempotency_key | string | optional | Up to 200 characters. See Idempotency. |
curl -X POST "https://streamfirefly.com/api/v1/points/add" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here" \
-H "Content-Type: application/json" \
-d '{
"platform": "twitch",
"username": "ava",
"amount": 500,
"idempotency_key": "bossfight-2026-08-23-ava"
}'{
"ok": true,
"data": {
"platform": "twitch",
"user_id": "123456789",
"display_name": "Ava",
"requested": 500,
"amount": 500,
"balance": 4700,
"clamped": false,
"idempotent": false
}
}Takes points away, never below zero. Same body as /add, except that create is ignored.
| Body field | Type | Notes | |
|---|---|---|---|
| platform | string | required | twitch, youtube or kick. |
| user_id | string | one of | The platform id. |
| username | string | one of | Display name, case-insensitive. |
| amount | integer | required | A whole number from 1 to 10,000,000. |
| idempotency_key | string | optional | Up to 200 characters. See Idempotency. |
curl -X POST "https://streamfirefly.com/api/v1/points/remove" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here" \
-H "Content-Type: application/json" \
-d '{
"platform": "twitch",
"username": "ava",
"amount": 500
}'{
"ok": true,
"data": {
"platform": "twitch",
"user_id": "123456789",
"display_name": "Ava",
"requested": 500,
"amount": 200,
"balance": 0,
"clamped": true,
"idempotent": false
}
}requested is what you asked, amount is what was actually taken, and clamped tells you they differ. A viewer with nothing to take returns 200 with amount: 0 — the state you asked for already holds, so it is not an error.Starts a Firesale — every item in your shop discounted for a set number of minutes. Requires Pro.
| Body field | Type | Notes | |
|---|---|---|---|
| percent | integer | required | A whole number from 1 to 100. 100 makes every item free. |
| duration_minutes | integer | required | A whole number from 1 to 1440. |
curl -X POST "https://streamfirefly.com/api/v1/firesale/start" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here" \
-H "Content-Type: application/json" \
-d '{
"percent": 50,
"duration_minutes": 10
}'{
"ok": true,
"data": {
"active": true,
"percent": 50,
"ends_at": "2026-09-03T20:10:00.000Z"
}
}ends_at with nothing to switch off — the discount is worked out each time a price is read, never written over your stored prices. Starting a second Firesale replaces the first. Your chat is told when one starts.percent: 100 every item costs nothing. Twitch cannot charge 0 Bits, so an item normally priced in Bits is redeemed with points at zero for the length of the sale instead. With no price, only your cooldowns limit how often viewers redeem.Ends the running Firesale early. Safe to call when nothing is running.
| Body field | Type | Notes |
|---|
curl -X POST "https://streamfirefly.com/api/v1/firesale/stop" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"{
"ok": true,
"data": {
"active": false,
"percent": 0,
"ends_at": null
}
}Whether a Firesale is running — enough for a Stream Deck button that shows its own state.
| Query parameter | Type | Notes |
|---|
curl "https://streamfirefly.com/api/v1/firesale/state" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"{
"ok": true,
"data": {
"active": true,
"percent": 50,
"ends_at": "2026-09-03T20:10:00.000Z"
}
}points:read, not firesale:write, so a button that renders the current state keeps working whatever your plan.Gives everyone in your Twitch chat a random reward from your drop table. Requires Pro.
| Body field | Type | Notes |
|---|
curl -X POST "https://streamfirefly.com/api/v1/drops/trigger" \
-H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"{
"ok": true,
"data": {
"drop_id": "3f9a1c2e-…",
"audience_size": 42,
"recipients": 42,
"total_points": 1430
}
}"In your chat" is literal — it is the chat room, not the page. On the Twitch mobile app a viewer joins chat only once they open the chat tab, so someone watching on mobile without it open is not in the audience yet.
total_points is what was awarded rather than what was spent. Tools → Activity shows how much of each drop was actually claimed.200 with "recipients": 0 rather than an error, so a button pressed a beat early does not look broken. If Twitch cannot be reached you get a 503 with a Retry-After instead — never a silent zero. audience_size is what Twitch reported and recipients is how many were awarded a box; they differ only if your chat is larger than 2,000.Streamer.bot
Any action StreamFirefly triggers already carries the viewer with it — %sfUsername% and %sfPlatform% among others — so an action can pay whoever set it off without knowing which platform they came from.
First, store your key once: Settings → Variables → Global Variables, a persisted variable named sfApiKey holding the key. Everything below reads it from there.
Reading a balance — no code needed
Add a Core → Network → Fetch URL sub-action. Paste this into the URL field:
https://streamfirefly.com/api/v1/points/balance?platform=%sfPlatform%&username=%sfUsername%Then fill in the rest of the sub-action:
| Field | Value |
|---|---|
| Headers | Add one row — name Authorization, value Bearer %sfApiKey%. |
| Parse result as JSON | On. |
| Variable name | sfPoints — this becomes the prefix on everything that comes back. |
The response is then available to every later sub-action in the action:
| Variable | Example value |
|---|---|
| %sfPoints.data.balance% | 4200 |
| %sfPoints.data.points_name% | Embers |
| %sfPoints.data.display_name% | Ava |
| %sfPoints.ok% | True |
data object in the middle is part of the name. An unresolved Streamer.bot variable prints its own name rather than erroring, so a wrong spelling looks like working config returning junk. Booleans render as True / False, so compare %sfPoints.ok% against True, not true.Awarding points
Fetch URL only supports GET, so awarding points needs a Core → C# → Execute C# Code sub-action. It needs no third-party imports — but it does need one reference added before it will compile.
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\System.dllStreamer.bot compiles against .NET Framework 4, and without this the
HttpClient lines will not build. Then press Compile.using System;
using System.Net.Http;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
public class CPHInline
{
// Static: one client for the lifetime of the code instance, per Streamer.bot's own guidance.
private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
public bool Execute()
{
// Read the key from a global variable. NEVER paste it into this file — see "Keeping your
// key safe" below.
var apiKey = CPH.GetGlobalVar<string>("sfApiKey", true);
if (string.IsNullOrWhiteSpace(apiKey)) return Fail("Global variable sfApiKey is not set.");
// Both arrive automatically on any action StreamFirefly triggers. Testing this action by
// hand? Then nothing supplies them, so set them yourself with Set Argument sub-actions.
CPH.TryGetArg("sfUsername", out string username);
CPH.TryGetArg("sfPlatform", out string platform);
if (string.IsNullOrWhiteSpace(username)) return Fail("No sfUsername argument.");
if (string.IsNullOrWhiteSpace(platform))
return Fail("No sfPlatform argument — set it to twitch, youtube or kick.");
// Set sfAmount in an earlier sub-action to make the award dynamic.
CPH.TryGetArg("sfAmount", out string rawAmount);
int amount;
if (!int.TryParse(rawAmount, out amount)) amount = 100;
var payload = new
{
platform = platform,
username = username,
amount = amount,
// Set this to true to pay someone who has never earned points here. On Twitch the name
// is verified before anything is created; see "Viewers we have never seen" above.
create = false,
// A fresh key per run: this makes each execution its own award, and lets a network
// retry of THIS request be safely repeated. Use a stable value (an event id, say) if
// you need two separate triggers to count as one award.
idempotency_key = Guid.NewGuid().ToString()
};
var body = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json");
_http.DefaultRequestHeaders.Clear();
_http.DefaultRequestHeaders.Add("Authorization", "Bearer " + apiKey);
try
{
var res = _http.PostAsync("https://streamfirefly.com/api/v1/points/add", body).GetAwaiter().GetResult();
var json = res.Content.ReadAsStringAsync().GetAwaiter().GetResult();
var parsed = JObject.Parse(json);
if (!res.IsSuccessStatusCode)
return Fail((int)res.StatusCode + " " + (string)parsed["error"] + ": " + (string)parsed["message"]);
// Available to later sub-actions as %sfNewBalance%.
CPH.SetArgument("sfNewBalance", (int)parsed["data"]["balance"]);
CPH.SetArgument("sfError", "");
return true;
}
catch (Exception e)
{
return Fail("Request failed: " + e.Message);
}
}
// Every failure lands in the Streamer.bot log AND in %sfError%, so a sub-action can show it in
// chat instead of you having to go and read the log to find out nothing happened.
private bool Fail(string reason)
{
CPH.LogWarn("[StreamFirefly] " + reason);
CPH.SetArgument("sfError", reason);
return false;
}
}Set sfAmount in an earlier sub-action to vary the award, and read %sfNewBalance% afterwards to announce the result in chat. To take points instead, point the same code at /api/v1/points/remove — and note that create is ignored there.
%sfUsername% and %sfPlatform% are supplied automatically only when StreamFirefly triggers the action. Running it yourself from the Actions list, nothing sets them — so add Set Argument sub-actions for both before the code runs, or the request is rejected for having no platform.Nothing happening at all? Every failure is written to %sfError% as well as the Streamer.bot log, so add a Send Message sub-action after the code — or search the log for [StreamFirefly]. It will name the exact reason: a missing argument, a key without the points:write scope, or a viewer who holds no balance in your channel yet.
Keeping your key safe
sfApiKey global variable means your exports carry only the variable's name.- Name each key after where it lives ("Streamer.bot", "my overlay"), so if one leaks you know which to revoke.
- The dashboard shows when each key was last used and from which IP — a key that is being used somewhere you did not expect is visible there.
- If a key does leak, revoke it. The damage is bounded to your own channel's points, every move it made is in your ledger tagged with that key, and you can reset balances.