Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Twitch (twitch)

Twitch is a live-streaming platform. Rustiz does not create streams or clips: a “post” is a chat message in the broadcaster’s own channel, or a /announcement highlighted in that chat. Threads become replies to the previous chat message.

  • Rust source: crates/rustiz-providers/src/twitch.rs
  • Upstream port: twitch.provider.ts
  • Editor: Normal
  • Connection mode: oauth
  • No sandbox and no app review for ordinary chat apps.

Overview

PropertyValue
Identifiertwitch
Display nameTwitch
Connection modeoauth (authorization code grant)
Editornormal
Channel kindsocial
Comments / threadsYes. supports_comments = true; each extra entry is a chat reply (reply_parent_message_id)
Pending / async publishingNo. supports_pending = false; post() sends and returns Posted/Success
AnalyticsNo
Post analyticsNo
MentionsNo
is_between_stepsfalse
convert_to_jpegfalse
max_concurrent_job5
Character limit (max_length)500 characters, hard-coded
OAuth scopesuser:write:chat, user:read:chat, moderator:manage:announcements
Redirect URI{FRONTEND_URL}/integrations/social/twitch

Server Environment Variables

required_env() returns exactly:

#![allow(unused)]
fn main() {
fn required_env(&self) -> &'static [&'static str] {
    &["TWITCH_CLIENT_ID", "TWITCH_CLIENT_SECRET"]
}
}

required_env_alternatives() is not overridden.

VariableRequiredDefaultPurpose
TWITCH_CLIENT_IDYes—Client id from the Twitch developer console; also sent as the Client-Id header on every Helix call
TWITCH_CLIENT_SECRETYes—Client secret; used for the authorization-code and refresh-token exchanges
TWITCH_ID_BASE_URLNohttps://id.twitch.tvOAuth origin (/oauth2/authorize, /oauth2/token). Read through Config::base_url("TWITCH_ID", …).
TWITCH_BASE_URLNohttps://api.twitch.tvHelix origin (/helix/users, /helix/chat/...). Read through Config::base_url("TWITCH", …).
FRONTEND_URLYes in practicehttp://localhost:5000Base of the redirect URI
JWT_SECRETNo direct useemptyNot read by the Twitch flow (the OAuth state row has no encrypted client info). The rest of the server uses it for other secrets.

rustiz providers --status reports twitch as configured only when both client values are non-empty. It cannot verify that the secret is current; Twitch invalidates the previous secret when you click New Secret.

Step-by-Step Developer App Setup

1. Prepare the Twitch account

  1. Sign in at https://dev.twitch.tv/console with the Twitch account that will own the app.
  2. Verify the account’s email.
  3. Enable two-factor authentication at https://www.twitch.tv/settings/security. Twitch requires 2FA to use the developer console. Refresh the console after enabling it.
  4. Open Applications → Register Your Application.

2. Register the app

FieldValue
NameAny unique name, e.g. Rustiz
OAuth Redirect URLshttps://social.example.com/integrations/social/twitch — then click Add
CategoryChoose the closest category (e.g. Chat Bot / Website)
Client typeConfidential / server-side (Rustiz stores a client secret)
CAPTCHAComplete it

After Create, open Manage and copy the Client ID. Click New Secret and copy the secret once; Twitch will not show it again.

Twitch allows multiple redirect URLs on one app. Add every deployment (production, staging, local) that will use the same client id. There is no product or API subscription to enable in the console; the app may call Helix immediately with the scopes the user grants.

3. Set the environment and restart

TWITCH_CLIENT_ID="…"
TWITCH_CLIENT_SECRET="…"

Restart rustiz serve, rustiz worker and rustiz mcp. Check:

rustiz providers --status

4. Redirect URI (exact)

Twitch::redirect_uri(None) is:

{FRONTEND_URL}/integrations/social/twitch
  • FRONTEND_URL is trimmed of trailing slashes, so register it without a trailing slash.
  • There is no redirectmeto.com wrapper for Twitch.
  • Local example: http://localhost:5000/integrations/social/twitch. Twitch accepts http://localhost redirects for development; for a public deployment use HTTPS.
  • The same URI is sent to /oauth2/authorize and /oauth2/token. Twitch requires them to match the registered value.

There is a refresh quirk: when a connection is started with a refresh query parameter, authenticate() sends redirect_uri as {FRONTEND_URL}/integrations/social/twitch?refresh=<internal-id> while generate_auth_url() still sends the URI without the query string. Twitch matches redirect URIs exactly, so a token exchange during a “reconnect/refresh” flow can fail with invalid redirect_uri. Normal CLI/MCP connections do not set refresh, so they are unaffected.

5. Scopes requested and checked

Twitch::new() sets:

#![allow(unused)]
fn main() {
info.scopes = vec![
    "user:write:chat",
    "user:read:chat",
    "moderator:manage:announcements",
];
}
ScopeUsed byWhy
user:write:chatPOST /helix/chat/messagesSend chat messages as the authenticated user
user:read:chatNothing in this providerEventSub channel.chat.message normally needs it; Rustiz never subscribes. It is an extra scope.
moderator:manage:announcementsPOST /helix/chat/announcementsSend highlighted announcements to the broadcaster’s chat

The authorize URL URL-encodes the space-joined list:

https://id.twitch.tv/oauth2/authorize?response_type=code
  &client_id=…
  &redirect_uri=…
  &scope=user%3Awrite%3Achat%20user%3Aread%3Achat%20moderator%3Amanage%3Aannouncements
  &state=…

check_scopes() is never called. The port does not read the scope array returned by the token exchange, so a user who unchecks a permission still gets a “connected” channel; the failing call appears later.

Twitch’s own guidance says an app must request only the scopes it uses, and may suspend access for over-requesting. user:read:chat is not used by Rustiz; removing it from info.scopes is safe for this provider and reduces review risk.

6. Sandbox / testing vs production

  • No sandbox. Twitch has no test-mode app. The app works against the production API as soon as it is created.
  • No app review for ordinary chat/Helix apps. The account owner can authorize the app immediately; the consent screen lists the scopes.
  • Test with your own Twitch channel: authorize the app with the broadcaster account, then send a message or an announcement. The message is visible to real viewers if the channel is live.
  • Use a separate Twitch account/channel for tests if you do not want test messages in a real chat.
  • Production has the same setup. Twitch may still suspend an app for scope misuse, spam or API terms violations; there is no pre-approval step that prevents that.

7. Token lifetime and validation

  • Authorization-code tokens include a refresh token. authenticate() stores expires_in from the token response (Twitch access tokens are typically valid for about 4 hours; the exact value is in the response).
  • refresh_token() performs the refresh grant and fetches /helix/users again, so the stored id/name/avatar stay current.
  • Twitch’s docs require third-party apps that maintain an OAuth session to call GET /oauth2/validate periodically. Rustiz does not call /validate. It relies on API 401s to trigger the refresh path.
  • A new client secret invalidates the old one; update TWITCH_CLIENT_SECRET and reconnect if refreshes start failing.

Connecting a Channel (CLI & MCP)

CLI

rustiz channels connect twitch --open
  • Prints the Twitch consent URL; --open opens it.
  • With a local FRONTEND_URL, the CLI serves the callback on the frontend port and waits up to --timeout (default 300 s).
  • With a public HTTPS FRONTEND_URL, the CLI prints the URL and exits; the server handles the callback. Poll rustiz channels connect-status <id> or rustiz channels list.
  • No --external-url and no --fields: Twitch is a normal OAuth provider. Passing customFields routes to connect_custom(), which encodes the object as an authorization code and fails. Do not send it.

MCP connectChannelTool

{ "provider": "twitch" }

Returns:

{
  "connected": false,
  "pending": true,
  "requiresHuman": true,
  "connectionId": "…",
  "url": "https://id.twitch.tv/oauth2/authorize?...",
  "message": "A human must open this URL and log in. Then poll with connectionId or check integrationList."
}

Poll with:

{ "connectionId": "…" }

integrationSchema reports:

{ "type": "oauth", "headless": false, "requiresHuman": true, "requiresExternalUrl": false }

There is no triggerTool / channelSetupTool follow-up: the broadcaster is the authenticated user, so is_between_steps is false.

What gets stored

GET /helix/users returns the user’s id, display_name, profile_image_url and login. AuthTokenDetails stores:

  • id = Twitch user id (used as broadcaster_id and sender_id)
  • name = display name
  • username = login
  • access_token, refresh_token, expires_in
  • picture = profile image URL

If /helix/users returns an empty data array, the channel can still be saved with empty id/name because save() only requires a non-empty access token. That channel will fail every publish with a missing/invalid broadcaster id.

Post Settings & Media Validation

settings_schema()

{
  "type": "object",
  "properties": {
    "messageType": {
      "type": "string",
      "enum": ["message", "announcement"],
      "description": "Publish a normal chat message or a channel announcement (default message)"
    },
    "announcementColor": {
      "type": "string",
      "enum": ["primary", "blue", "green", "orange", "purple"],
      "description": "Color of the announcement, only used when messageType is announcement (default primary)"
    }
  },
  "additionalProperties": true
}

Defaults when omitted: messageType = "message", announcementColor = "primary".

validate_settings() rules

  1. If messageType is present and not null, it must be exactly "message" or "announcement". Any other string, number or object fails with messageType must be message or announcement.
  2. If announcementColor is present and not null, it must be exactly "primary", "blue", "green", "orange" or "purple". Otherwise it fails with announcementColor must be primary, blue, green, orange or purple.
  3. A null value is accepted and falls back to the default at publish time.
  4. Extra properties are allowed.

The same settings are read for comments, so a comment can be an announcement too.

check_validity()

Not overridden. There are no pre-flight media rules.

Media requirements

Media is ignored. Twitch chat cannot attach images or video. post() and comment() never read post.media; a post with images publishes as text only, with no warning. This is a silent data loss, not an error.

Message length

  • max_length() returns 500.
  • post() and comment() also truncate to 500 chars before sending (truncate_chars).
  • The engine counts characters after HTML stripping and rejects a longer post before it reaches Twitch; the provider truncation is a second guard.
  • Twitch’s docs say the message is limited to 500 characters. Emote names are sent as plain text and Twitch converts recognized names; Rustiz does not add colons.

Chat message vs announcement

Normal message

POST {TWITCH_BASE_URL}/helix/chat/messages
Authorization: Bearer <user token>
Client-Id: <TWITCH_CLIENT_ID>
Content-Type: application/json

{
  "broadcaster_id": "<integration.internal_id>",
  "sender_id": "<integration.internal_id>",
  "message": "<truncated text>",
  "reply_parent_message_id": "<optional, comments only>"
}
  • broadcaster_id and sender_id are both the authenticated user’s id. This posts to the user’s own channel only; Rustiz cannot post to someone else’s channel.
  • Twitch requires sender_id to match the user in the access token.
  • The response is { data: [{ message_id, is_sent, drop_reason? }] }.
  • If is_sent is false, Rustiz maps it to PostStatus::Success (the Rust enum has no error state). The drop reason is discarded. A dropped message looks successful.
  • If the response has no message_id, Rustiz generates a random 10-char id.

Announcement

POST {TWITCH_BASE_URL}/helix/chat/announcements
  ?broadcaster_id=<id>&moderator_id=<id>
Authorization: Bearer <user token>
Client-Id: <TWITCH_CLIENT_ID>
Content-Type: application/json

{ "message": "<truncated text>", "color": "primary|blue|green|orange|purple" }
  • broadcaster_id and moderator_id are both the authenticated user’s id. The user must be the broadcaster or a moderator.
  • Twitch returns 204 No Content on success. The provider uses execute() (not send_json) because the retrying client would treat 204 as an error.
  • Any non-2xx is converted to bad_body with the fixed message Unknown Error.
  • Announcements do not return a message id, so Rustiz generates a random 10-char post_id.
  • Twitch rate-limits announcements to one every 2 seconds per channel. The provider sleeps 2 seconds before every post/comment, which is a rough match but not a strict scheduler.

Threads

supports_comments = true:

  • The first entry is a normal message or announcement.
  • Each later entry sets reply_parent_message_id to last_comment_id when present, otherwise to the root post_id.
  • For a normal message, this creates a Twitch chat reply.
  • For an announcement, the code takes the announcement branch and ignores reply_parent_message_id; a thread of announcements becomes independent announcements, not replies.
  • Twitch replies are only one level deep in the UI; there is no root/thread metadata.

Plugs

No @Plug / @PostPlug methods. run_plug() and run_post_plug() return Unsupported.

Implementation Notes & Gotchas

  • user:read:chat is unused. Rustiz never subscribes to EventSub or reads chat. Twitch may suspend an app that requests scopes it does not need. Remove it from info.scopes if you control the deployment and want the minimal scope set.
  • No channel:bot / user:bot. The app sends as the authenticated user, not as a bot. This is fine for posting to the user’s own channel; it cannot join another channel’s chat as a bot.
  • Refresh redirect mismatch. As described above, a reconnect started with ?refresh= builds a token-exchange redirect_uri that includes the query string while the authorize request did not. Twitch’s exact-match rule can reject it. Normal connections are unaffected.
  • No PKCE. Twitch’s authorization-code flow uses the client secret, and Rustiz sends it in the token form. That is correct for a confidential client, but it means the secret must be protected.
  • Authorization: Bearer on Helix, not OAuth. Twitch accepts both for Helix, but the code uses Bearer and always sends Client-Id.
  • Token refresh does not send scope. Twitch’s refresh grant does not require it; the new token keeps the originally granted scopes.
  • No /oauth2/validate. Twitch’s docs require third-party apps to validate tokens periodically. Rustiz waits for an API 401 instead. A token can be revoked while the channel still looks healthy in Rustiz.
  • A 401 becomes a refresh error. The generic HTTP layer classifies an unhandled 401 as RefreshToken; the engine calls refresh_token(). If the refresh token is also invalid, the channel is marked for reconnect.
  • No error classification. handle_errors() returns None; Twitch’s JSON error bodies are passed through the generic layer. send_announcement() replaces every non-2xx with Unknown Error, losing Twitch’s message.
  • is_sent: false is hidden. A message dropped by AutoMod, a slow mode, a ban or a follower-only mode is reported as Success, not an error. The drop_reason object is discarded. Check Twitch chat if a post claims success but does not appear.
  • Announcements have a random post id. post_id is make_id(10), not a Twitch identifier. The release URL is just the channel URL, so post analytics/deletion cannot target the announcement.
  • Release URL is the channel, not the message. https://twitch.tv/{login} (or provider_identifier if profile is empty). Twitch chat messages have no stable public permalink. A missing profile produces https://twitch.tv/twitch, which is wrong.
  • 2-second sleep on every send. post() and comment() sleep 2 seconds before doing anything. This throttles bursts but also adds latency. Combined with max_concurrent_job = 5, a queue can still send several messages at once; the sleep is not a global lock.
  • Rate limits. Twitch Helix uses a points bucket (commonly 800 points per minute per user token) and returns 429 with Ratelimit-* headers. Chat sends have their own limits; announcements are documented as one per 2 seconds. The generic HTTP layer retries 429/5xx up to three times with the configured delay; the 2-second sleep reduces but does not eliminate 429s.
  • Media is silently dropped. Twitch chat has no attachments. A post with images publishes as text; check_validity() does not warn.
  • max_length is 500 Unicode scalar values. Twitch’s docs say the message is limited to 500 characters. Rust counts chars, so a grapheme cluster made of several code points (many emoji, flags, skin-tone sequences) consumes more than one unit and can be truncated earlier than the Twitch UI would.
  • The app is for one user. broadcaster_id/sender_id are always the authenticated user. To post to multiple channels, connect each broadcaster account as a separate channel; Rustiz cannot target arbitrary channels with one token.
  • No analytics or mentions. Both flags are false; there are no overrides.

References