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.

Base URLhttps://streamfirefly.com

Overview

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.

The Points API is freeEvery channel can create keys and use both permissions, on any plan. Pro raises your rate limit — see Rate limits — but it is never required to read or move points.

Authentication

Create a key in Dashboard → Settings → Points API keys. It looks like this, and is shown exactly once:

Key format
sf_live_xxxxxxxxxxxx_your-43-character-secret

Send 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.

Header
Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here
Never put the key in a URLQuery strings are recorded by things request bodies and headers are not — server access logs keep the full request line, and any inspecting proxy sees it. There is no query-string authentication, and there will not be one. A bearer header on a GET is perfectly fine.

Keys 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.

Envelope
// success
{ "ok": true, "data": { ... } }

// failure
{ "ok": false, "error": "unknown_target", "message": "No viewer found with a balance in this channel." }

Errors

CodeHTTPMeaning
invalid_request400Something in the request is malformed — read message for which field.
unauthorized401Missing, malformed, unknown or revoked key. Every credential failure returns this same response.
forbidden403The key is valid but lacks the scope this endpoint requires.
unknown_target404No viewer matched in this channel. Also returned for a viewer we have never seen.
conflict409That idempotency_key was already used for a different operation.
rate_limited429Too many requests. Honour the Retry-After header.
unavailable503Twitch could not be reached to verify a new viewer. Honour the Retry-After header.
server_error500Something failed on our side. Safe to retry with the same idempotency_key.
404 does not tell you whether an account exists"No such viewer" and "a viewer with no balance in this channel" return the identical response, deliberately: a key must not be usable to probe which accounts exist on StreamFirefly. Treat a 404 as "not spendable here".

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.

platform is always requiredViewers are keyed by platform and platform id, and the id namespaces collide — Twitch ids and Kick ids are both plain numbers. Without the platform, one id could resolve to two different people, so we refuse to guess rather than risk paying the wrong one. Linked accounts are not affected: naming either side of a linked pair reaches the same balance.
  • user_id is the platform's own id — numeric on Twitch and Kick, the UC… channel id on YouTube. It is authoritative and never ambiguous.
  • username matches 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 404 rather than a viewer who can never spend what you paid them. 🔴 Prefer user_id for 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 by username, which draws on the budgets below each time. Writes by user_id never do that once the viewer exists.
  • On YouTube and Kick there is no username lookup available to us, so create: true is taken on trust. A user_id typo there creates a balance nobody can reach — double-check ids on those platforms, and note that creating from a username is refused outright.
  • create does not apply to /remove: a viewer with no balance in your channel is refused with 404 unknown_target.
  • If Twitch itself cannot be reached, you get 503 with a Retry-After rather than a 404 — 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: true can return 429 even 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

GET/api/v1/points/balancepoints:read

One viewer's balance in your channel, plus what you call your points.

Query parameterTypeNotes
platformstringrequiredtwitch, youtube or kick.
user_idstringone ofThe platform id. display_name comes back null on this path.
usernamestringone ofDisplay name, case-insensitive.
Request
curl "https://streamfirefly.com/api/v1/points/balance?platform=twitch&username=ava" \
  -H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"
Response
{
  "ok": true,
  "data": {
    "platform": "twitch",
    "user_id": "123456789",
    "display_name": "Ava",
    "balance": 4200,
    "points_name": "Embers"
  }
}
GET/api/v1/points/leaderboardpoints:read

The highest balances in your channel, ranked. Viewers with no points are omitted.

Query parameterTypeNotes
limitintegeroptionalBetween 1 and 100. Defaults to 10.
Request
curl "https://streamfirefly.com/api/v1/points/leaderboard?limit=3" \
  -H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"
Response
{
  "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"
  }
}
POST/api/v1/points/addpoints:write

Awards points to a viewer. The new balance comes back, so you can announce it without a second request.

Body fieldTypeNotes
platformstringrequiredtwitch, youtube or kick.
user_idstringone ofThe platform id.
usernamestringone ofDisplay name, case-insensitive.
amountintegerrequiredA whole number from 1 to 10,000,000.
createbooleanoptionalAdd a viewer we have not seen. Verified on Twitch; taken on trust elsewhere.
display_namestringoptionalA name to store if the viewer is created. Ignored on Twitch, where the real one is fetched.
idempotency_keystringoptionalUp to 200 characters. See Idempotency.
Request
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"
  }'
Response
{
  "ok": true,
  "data": {
    "platform": "twitch",
    "user_id": "123456789",
    "display_name": "Ava",
    "requested": 500,
    "amount": 500,
    "balance": 4700,
    "clamped": false,
    "idempotent": false
  }
}
POST/api/v1/points/removepoints:write

Takes points away, never below zero. Same body as /add, except that create is ignored.

Body fieldTypeNotes
platformstringrequiredtwitch, youtube or kick.
user_idstringone ofThe platform id.
usernamestringone ofDisplay name, case-insensitive.
amountintegerrequiredA whole number from 1 to 10,000,000.
idempotency_keystringoptionalUp to 200 characters. See Idempotency.
Request
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
  }'
Response
{
  "ok": true,
  "data": {
    "platform": "twitch",
    "user_id": "123456789",
    "display_name": "Ava",
    "requested": 500,
    "amount": 200,
    "balance": 0,
    "clamped": true,
    "idempotent": false
  }
}
Removals are clampedIf a viewer holds less than you asked for, they are taken to zero rather than refused: 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.
POST/api/v1/firesale/startfiresale:write

Starts a Firesale — every item in your shop discounted for a set number of minutes. Requires Pro.

Body fieldTypeNotes
percentintegerrequiredA whole number from 1 to 100. 100 makes every item free.
duration_minutesintegerrequiredA whole number from 1 to 1440.
Request
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
  }'
Response
{
  "ok": true,
  "data": {
    "active": true,
    "percent": 50,
    "ends_at": "2026-09-03T20:10:00.000Z"
  }
}
It ends by itselfPrices return to normal at 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.
Bits prices land on a Twitch tierTwitch products come from a fixed ladder of amounts, so a discounted Bits price lands on the nearest registered tier at or below the original — never above it. The effective discount therefore varies per item, which is why every surface shows each item's own price rather than a percentage. Above 25 Bits it stays within about 10 points of what you asked for, and where the ladder cannot get close the item simply keeps its price. Below 25 Bits the rungs are 1, 5 and 10, so a large discount there can only land on 1 — a few Bits either way. Points prices are exact, and never fall below 1 — except at 100.
100 means freeAt 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.
POST/api/v1/firesale/stopfiresale:write

Ends the running Firesale early. Safe to call when nothing is running.

Body fieldTypeNotes
Request
curl -X POST "https://streamfirefly.com/api/v1/firesale/stop" \
  -H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"
Response
{
  "ok": true,
  "data": {
    "active": false,
    "percent": 0,
    "ends_at": null
  }
}
GET/api/v1/firesale/statepoints:read

Whether a Firesale is running — enough for a Stream Deck button that shows its own state.

Query parameterTypeNotes
Request
curl "https://streamfirefly.com/api/v1/firesale/state" \
  -H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"
Response
{
  "ok": true,
  "data": {
    "active": true,
    "percent": 50,
    "ends_at": "2026-09-03T20:10:00.000Z"
  }
}
Reading is not a Pro featureThis one takes points:read, not firesale:write, so a button that renders the current state keeps working whatever your plan.
POST/api/v1/drops/triggerdrops:write

Gives everyone in your Twitch chat a random reward from your drop table. Requires Pro.

Body fieldTypeNotes
Request
curl -X POST "https://streamfirefly.com/api/v1/drops/trigger" \
  -H "Authorization: Bearer sf_live_xxxxxxxxxxxx_your-secret-here"
Response
{
  "ok": true,
  "data": {
    "drop_id": "3f9a1c2e-…",
    "audience_size": 42,
    "recipients": 42,
    "total_points": 1430
  }
}
No body — the rewards are your settingsThe tiers come from Tools → Drops, so there is nothing to send and nothing to get wrong on a Stream Deck button. Each viewer rolls separately, and the chances always total 100%, so everyone in chat receives something. If you have never saved a table, the first drop uses the default rewards and saves them for you — the button works before you have opened the page, and you can retune it afterwards.
Twitch chat, not just the shopEveryone in your chat when you fire it is awarded a reward — lurkers included. Nobody needs the shop or an extension open at the time; anyone who was not looking finds their box waiting next time they are. It is Twitch-only: it is the one platform that reports who is watching rather than only who has typed.

"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.
Points are credited when the viewer opens their boxA drop hands every chatter an unopened box. The reward is rolled the moment you fire it — so the result is already fixed and nobody can influence it by waiting — but the points reach their balance only when they open it, in the shop or an extension. An unopened box expires after 7 days and is never credited, so total_points is what was awarded rather than what was spent. Tools → Activity shows how much of each drop was actually claimed.
An empty chat is a successWith nobody in chat this returns 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:

URL
https://streamfirefly.com/api/v1/points/balance?platform=%sfPlatform%&username=%sfUsername%

Then fill in the rest of the sub-action:

FieldValue
HeadersAdd one row — name Authorization, value Bearer %sfApiKey%.
Parse result as JSONOn.
Variable namesfPoints — this becomes the prefix on everything that comes back.

The response is then available to every later sub-action in the action:

VariableExample value
%sfPoints.data.balance%4200
%sfPoints.data.points_name%Embers
%sfPoints.data.display_name%Ava
%sfPoints.ok%True
It is %sfPoints.data.balance%, not %sfPoints.balance%Fetch URL flattens the whole JSON response into dotted variables, and only leaf values become variables — so the 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.

Add System.dll to References firstIn the code editor, open References and add:
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\System.dll
Streamer.bot compiles against .NET Framework 4, and without this the HttpClient lines will not build. Then press Compile.
Execute C# Code sub-action
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.

Testing the action by hand%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

Never paste your key into an action you might shareStreamer.bot import and export strings are compressed and base64-encoded, not encrypted — anyone can read what is inside one. An action with the key typed into it leaks that key to everyone you send it to, while looking perfectly opaque. Keeping the key in the 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.
Points API — StreamFirefly