# Developer Support
# Overview
The Joystick bot API lets a third‑party application act on a streamer’s behalf — reading and sending chat, reacting to stream events, and moderating — once that streamer has authorized it. Authorization uses OAuth 2.0; realtime traffic flows over a WebSocket gateway (the Rails ActionCable protocol), and a REST API covers everything that isn’t realtime.
Every integration is the same shape: a bot (your registered OAuth application) plus a bot installation (one streamer’s grant to your bot). One streamer installing your bot creates one installation; your app holds a token per installation and acts within the scopes that streamer granted.
Host: all API calls go to api.joystick.tv — the OAuth token endpoints, the REST API, and the WebSocket gateway. The one exception is the consent screen, which lives on the main site at https://joystick.tv/api/oauth/authorize (that’s the page the streamer sees and approves).
Here’s the flow end to end:
As a bot developer:
- Create a bot application at joystick.tv and choose whether it’s a confidential or public client (see below).
- You receive OAuth credentials — a Client ID (and, for confidential clients, a Client Secret).
As a streamer installing a bot:
- The bot sends you to the Joystick consent screen listing the permissions it’s requesting.
- You approve, and Joystick redirects back to the bot with a short‑lived authorization code.
- The bot exchanges that code for an access token (and a refresh token).
- The bot uses the access token to connect to the gateway and call the REST API on your behalf.
# Clients: public vs confidential
When you create a bot you choose a client type. This is separate from whether your bot is listed in the marketplace (see Public vs private bots at the end) — it determines how your bot authenticates.
| Confidential client | Public client | |
|---|---|---|
| Who | Server‑side bots that can keep a secret | Desktop / installed apps (e.g. Streamer.bot) that can’t hide a secret |
| Auth | Client Secret (HTTP Basic) | PKCE (RFC 7636) — no secret |
| Redirect URI | Exact match | Exact match, or a loopback (http://127.0.0.1/… / http://localhost/…) with any port (RFC 8252) |
| Access token lifetime | 10 days | 4 hours |
Refresh tokens last 30 days for both and rotate on each use (see Refreshing).
If you’re building a desktop app, you want a public client: you can’t ship a secret in a binary, so you use PKCE and register a loopback redirect like http://127.0.0.1/callback. Joystick will accept that callback on whatever local port your app happens to bind.
# Scopes
A bot requests scopes — the specific things it wants to do. The streamer sees these on the consent screen and can decline any bot whose scopes they’re not comfortable with. Some scopes are sensitive and are highlighted for the streamer.
| Scope | Grants | Sensitive |
|---|---|---|
identity:read |
Read the streamer’s profile (username, display name, avatar, ID) | |
stream:read |
Read public stream state and receive stream events | |
chat:read |
Receive chat messages | |
chat:write |
Send chat messages | |
followers:read |
List the streamer’s followers (usernames only) | |
moderators:read |
List the streamer’s moderators (usernames only) | |
subscribers:read |
List the streamer’s subscribers (usernames only) | ⚠️ |
chat:whisper:write |
Send whispers as the streamer | ⚠️ |
chat:moderate |
Delete messages, ban, and time out users | ⚠️ |
moderators:manage |
Add and remove the streamer’s moderators | ⚠️ |
stream:manage |
Update the streamer’s stream settings | ⚠️ |
You request scopes as a space‑separated string in the scope parameter (e.g. chat:read chat:write stream:read). A token can only ever be narrower than what the streamer granted at install — requesting a scope the streamer didn’t grant is rejected. If you omit scope, the token carries everything the streamer granted.
The legacy value
scope=botstill works and means “everything this installation granted.” New integrations should request explicit scopes.
# Authorizing your bot
# Step 1 — Send the streamer to the consent screen
Redirect the streamer’s browser to:
https://joystick.tv/api/oauth/authorize
Query parameters:
response_type— Required. Must becode.client_id— Required. Your bot’s Client ID.scope— The space‑separated scopes you’re requesting (see Scopes).redirect_uri— Where to send the streamer back. Required for public clients.state— Optional. An opaque value echoed back to you; use it to protect against CSRF/MITM.
Public clients must also send a PKCE challenge:
code_challenge— Base64URL ofSHA256(code_verifier), 43 characters, no padding.code_challenge_method— Must beS256. (plainis not accepted.)
Where code_verifier is a high‑entropy random string you generate and keep; you’ll send it later at the token step.
Example (public client):
https://joystick.tv/api/oauth/authorize?response_type=code&client_id=00000000-0000-0000-0000-000000000000&scope=chat%3Aread%20chat%3Awrite%20stream%3Aread&redirect_uri=http%3A%2F%2F127.0.0.1%3A7395%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=myspecialtoken
After the streamer approves, Joystick redirects to your redirect_uri with code and state query params.
If the
stateyou receive doesn’t match the one you sent, stop — treat it as a tampered request and discard the exchange.
Authorization codes are short‑lived — they expire in 10 minutes and are single‑use.
# Step 2 — Exchange the code for tokens
POST to the token endpoint (note: api.joystick.tv):
https://api.joystick.tv/api/oauth/token
Send these as form parameters:
grant_type—authorization_codecode— The authorization code from Step 1.redirect_uri— The sameredirect_uriyou used in Step 1.code_verifier— Public clients only. The verifier whose SHA‑256 you sent ascode_challenge.client_id— Public clients only. Your Client ID (public clients identify themselves here since they have no secret).
Headers:
Content-Type—application/x-www-form-urlencodedAccept—application/jsonAuthorization— Confidential clients only.Basic <base64(client_id:client_secret)>.X-JOYSTICK-STATE— Optional. Any value here is echoed back on the response.
Confidential client example:
curl -XPOST \
-H "Authorization: Basic YOUR_BASIC_KEY" \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
"https://api.joystick.tv/api/oauth/token" \
-d "grant_type=authorization_code&code=YOUR_CODE&redirect_uri=YOUR_REDIRECT_URI"
Public client example (PKCE):
curl -XPOST \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
"https://api.joystick.tv/api/oauth/token" \
-d "grant_type=authorization_code&code=YOUR_CODE&redirect_uri=http://127.0.0.1:7395/callback&client_id=YOUR_CLIENT_ID&code_verifier=YOUR_CODE_VERIFIER"
Returns:
{
"access_token": "A_JSON_WEB_TOKEN",
"token_type": "Bearer",
"expires_in": 14400,
"refresh_token": "A_REFRESH_TOKEN"
}
access_token— A JWT. Present it asAuthorization: Bearer <access_token>on REST calls, and as the?token=on the gateway connection.expires_in— Lifetime in seconds (e.g.14400= 4 hours for a public client,864000= 10 days for a confidential client).refresh_token— Use this to get a new access token when the current one expires.
client_credentialsis not supported — every bot acts through a streamer’s installation, so there is always an authorization‑code + refresh‑token flow.
# Refreshing the access token
Access tokens expire. When yours does, exchange your refresh token for a fresh pair. POST to the same token endpoint:
https://api.joystick.tv/api/oauth/token
Form parameters:
grant_type—refresh_tokenrefresh_token— Your most recent refresh token.client_id— Public clients include their Client ID.
Headers are the same as Step 2 (Basic auth for confidential clients; none required for public clients).
curl -XPOST \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
"https://api.joystick.tv/api/oauth/token" \
-d "grant_type=refresh_token&refresh_token=YOUR_REFRESH_TOKEN&client_id=YOUR_CLIENT_ID"
The response is the same shape as before, with a new access_token and a new refresh_token.
Refresh tokens rotate. Each refresh invalidates the token you just used and returns a new one — always store the newest. If a previously‑used (or revoked) refresh token is presented again, every outstanding token for that installation is revoked as a safety measure. Never reuse an old refresh token.
# Inspecting a token
Use introspection (RFC 7662) to check whether a token is still valid — handy at startup:
curl -XPOST \
-H "Authorization: Basic YOUR_BASIC_KEY" \
-H "Content-Type: application/x-www-form-urlencoded" \
"https://api.joystick.tv/api/oauth/introspect" \
-d "token=THE_TOKEN"
(Public clients send client_id=YOUR_CLIENT_ID as a form param instead of the Basic header.)
Returns, for a live token:
{
"active": true,
"client_id": "YOUR_CLIENT_ID",
"username": "streamer_username",
"exp": 1782000000,
"iat": 1781985600,
"sub": "the_installation_id",
"token_type": "Bearer"
}
An expired, revoked, unknown, or another bot’s token returns simply { "active": false }.
# Revoking access
To immediately invalidate a token (RFC 7009) — for example when a streamer uninstalls in your app:
curl -XPOST \
-H "Authorization: Basic YOUR_BASIC_KEY" \
-H "Content-Type: application/x-www-form-urlencoded" \
"https://api.joystick.tv/api/oauth/revoke" \
-d "token=THE_REFRESH_TOKEN"
(Public clients authenticate with client_id alone.) The endpoint always returns 200 — pass the refresh token to kill the installation’s tokens.
# Connecting the bot
Realtime chat and events flow over a single WebSocket. Open a connection to:
wss://api.joystick.tv/cable?token=YOUR_ACCESS_TOKEN
using the ?token= you received from the token endpoint (the JWT access token), and the WebSocket subprotocol actioncable-v1-json.
If your WebSocket library doesn’t send a subprotocol automatically, set the request header
Sec-WebSocket-Protocol: actioncable-v1-json. Sending it matters — it’s how the edge recognizes the connection.
One connection serves your whole application. Every streamer who installed your bot is reachable over this same socket, keyed by a channelId (see below).
# Subscribing
Once the socket is open, send a subscribe message (JSON). Opt into the v2 event envelope — the current, uniform event format — by passing event_version:
{
"command": "subscribe",
"identifier": "{\"channel\":\"GatewayChannel\",\"event_version\":\"v2\"}"
}
On success you receive:
{ "type": "confirm_subscription", "identifier": "{\"channel\":\"GatewayChannel\",\"event_version\":\"v2\"}" }
and on failure type: "reject_subscription". If the connection is unauthorized, the socket closes with code 4401.
Omitting
event_versiongives you the older v1 envelope (documented under the legacy format). New integrations should use v2.
# Receiving events
PING — a keep‑alive. type is ping, message is a unix timestamp. (ActionCable sends this as a message, not a protocol ping, for universal client support.)
{ "type": "ping", "message": 1782000000 }
Events (v2 envelope) — every event your bot receives shares one uniform shape; only data differs by event type:
{
"message": {
"v": 2,
"type": "chat_message",
"id": "a-uuid",
"channel_id": "the-channel-hash",
"occurred_at": "2026-07-15T18:29:49Z",
"text": "a human-readable summary",
"data": { }
}
}
type— the event, snake_cased (chat_message,tipped,followed,subscribed,enter_stream,leave_stream,wheel_spin_claimed,stream_started,stream_ending, …).channel_id— a stable hash identifying the streamer. It never changes, even if the streamer renames. You use this value to send actions back to that channel.occurred_at— RFC 3339 timestamp.text— a ready‑made display string (some count events, like follower/subscriber/viewer counts, leave this blank).data— the event‑specific payload (a real JSON object — no double‑parsing).
A chat message (type: "chat_message") carries the full message in data — author, streamer, text, emotes, moderator/subscriber flags, etc. Presence events (enter_stream / leave_stream) carry data: { "username": "..." }. Stream events carry their metadata in data.
The list of stream event types grows over time. Treat unknown
types gracefully.
# Sending actions
To act on a channel, send a message command whose data names an action and includes the target channel’s channelId. Everything in data is a JSON‑encoded string.
Send a chat message — needs chat:write:
{
"command": "message",
"identifier": "{\"channel\":\"GatewayChannel\"}",
"data": "{\"action\":\"send_message\",\"text\":\"Hello World\",\"channelId\":\"THE_CHANNEL_ID\"}"
}
Send a whisper — needs chat:whisper:write:
{
"command": "message",
"identifier": "{\"channel\":\"GatewayChannel\"}",
"data": "{\"action\":\"send_whisper\",\"username\":\"someviewer\",\"text\":\"a secret\",\"channelId\":\"THE_CHANNEL_ID\"}"
}
Delete a message — needs chat:moderate. Pass the messageId:
{ "command": "message", "identifier": "{\"channel\":\"GatewayChannel\"}", "data": "{\"action\":\"delete_message\",\"messageId\":\"UUID\",\"channelId\":\"THE_CHANNEL_ID\"}" }
Mute (time out) a user — needs chat:moderate. Pass the messageId; the message’s author is muted:
{ "command": "message", "identifier": "{\"channel\":\"GatewayChannel\"}", "data": "{\"action\":\"mute_user\",\"messageId\":\"UUID\",\"channelId\":\"THE_CHANNEL_ID\"}" }
Unmute a user — needs chat:moderate. Pass the username:
{ "command": "message", "identifier": "{\"channel\":\"GatewayChannel\"}", "data": "{\"action\":\"unmute_user\",\"username\":\"someviewer\",\"channelId\":\"THE_CHANNEL_ID\"}" }
Ban (block) a user — needs chat:moderate. Pass the messageId; the message’s author is banned:
{ "command": "message", "identifier": "{\"channel\":\"GatewayChannel\"}", "data": "{\"action\":\"block_user\",\"messageId\":\"UUID\",\"channelId\":\"THE_CHANNEL_ID\"}" }
Bans are serious — each one alerts Joystick staff to investigate potential harassment. To un‑ban a user, use the REST endpoint (
DELETE /api/v1/chat/users/:username/ban).
Actions succeed silently — the resulting message or moderation shows up on the gateway stream. If something goes wrong (missing permission, bad target), you receive an error frame: { "error": "..." }.
# REST API
Everything that isn’t realtime is REST, under https://api.joystick.tv/api/v1. Send:
Authorization: Bearer <access_token>— the JWT from the token endpoint.Content-Type: application/json
Each endpoint requires a scope (shown below). All paths are relative to https://api.joystick.tv/api/v1.
# Identity & installation
| Method & path | Scope | Returns |
|---|---|---|
GET /me |
— | Installation summary: { channel, bot, permissions: [scopes] } |
GET /me/identity |
identity:read |
{ channel_id, username, nickname, photo_url, live } |
# Stream
| Method & path | Scope | Returns |
|---|---|---|
GET /me/stream |
stream:read |
{ channel_id, live, live_at, stream_title, view_count, tags, online_photo_url, offline_photo_url } |
PUT /me/stream |
stream:manage |
Update stream_title, chat_welcome_message, and/or tags. Returns the updated stream. |
curl -XPUT -H "Authorization: Bearer JWT" -H "Content-Type: application/json" \
"https://api.joystick.tv/api/v1/me/stream" \
-d '{"stream_title":"New title","tags":["irl","chatty"]}'
# Chat
| Method & path | Scope | Notes |
|---|---|---|
POST /chat/messages |
chat:write |
Body { "text": "..." }. Returns { sent, send_mode, text }. |
POST /chat/whispers |
chat:whisper:write |
Body { "text": "...", "username": "..." }. Returns { sent, username }. |
DELETE /chat/messages/:id |
chat:moderate |
Soft‑deletes a message in this channel. |
# Moderation
| Method & path | Scope | Notes |
|---|---|---|
POST /chat/messages/:id/ban |
chat:moderate |
Bans the message’s author. |
POST /chat/messages/:id/mute |
chat:moderate |
Times out the message’s author. |
DELETE /chat/users/:username/ban |
chat:moderate |
Un‑bans a user. |
POST /moderators |
moderators:manage |
Body { "username": "..." }. Adds a moderator. |
DELETE /moderators/:username |
moderators:manage |
Removes a moderator. |
# Lists (usernames only, paginated)
Use page (default 1) and per_page (default 20, max 100).
| Method & path | Scope |
|---|---|
GET /followers |
followers:read |
GET /moderators |
moderators:read |
GET /subscribers |
subscribers:read |
{
"items": [ { "username": "someone" }, ... ],
"pagination": { "next_page": "...", "previous_page": "...", "total_items": 30, "total_pages": 3 }
}
# Banned words
| Method & path | Scope | Notes |
|---|---|---|
GET /banned-words |
stream:manage |
{ banned_words: [...] } |
POST /banned-words |
stream:manage |
Body { "word": "..." }. Adds a banned word. |
DELETE /banned-words |
stream:manage |
Body { "word": "..." }. Removes a banned word. |
# Testing your bot
Testing is tricky if you’re not a streamer, since you need a live chat. Confidential clients can use the echo endpoint to fire sample data at their own gateway connection:
curl -XPOST \
-H "Authorization: Basic YOUR_BASIC_KEY" \
-H "Content-Type: application/json" \
"https://api.joystick.tv/echo" \
-d '{"sample": {"event": "SendMessage", "data": "!test 123"}}'
The sample is delivered to your bot’s gateway stream as if it happened in chat. Options:
{"sample": {"event": "SendMessage", "data": "!join"}}— a chat message (great for command testing, e.g.!tip 123).{"sample": {"event": "EnterStream"}}/{"event": "LeaveStream"}— presence.{"sample": {"event": "StreamEvent", "data": "Tipped"}}or"TipMenu"— a stream event.
# Example bots
Reference implementations live on our GitHub: @joysticktv. These demonstrate the full lifecycle — PKCE login, connecting the gateway, and calling the REST API.
# Public vs private bots
Independently of client type, a bot is private by default — only you (the creator) can install it, and it’s not visible to anyone else. Making a bot public lists it in the Joystick bot marketplace so any streamer can install it; that requires providing:
- Website — a page describing what the bot does and how to configure it.
- Terms of Service — what you expect of streamers who install it.
- Privacy Policy — how you use their data.
Popular bots may require additional verification by Joystick staff.
# Legacy (v1) envelope
Bots that subscribe without event_version: "v2" receive the older per‑event envelope, where fields are camelCased (channelId, createdAt) and metadata is a JSON‑encoded string that must be parsed separately. For example a chat message arrives as { event: "ChatMessage", type: "new_message", text, author: {...}, streamer: {...}, channelId, createdAt, ... } and a stream event as { event: "StreamEvent", type: "Tipped", text, metadata: "{...}", channelId, createdAt }. New integrations should prefer the v2 envelope; v1 remains for backward compatibility.