The Secret Button That Wasn't There: scripting the Twitch API
I wanted to talk to Twitch from a script — nothing fancy, just things like “is this channel live right now,” “look up these users,” “what are the top games.” So I registered an app on the Twitch developer console, went to grab my client secret … and the button to generate one wasn't there. No secret, no token, no API. I stared at it for a minute before the penny dropped, and the reason is a genuinely useful thing to understand about how Twitch auth works. This is the whole path, including the part that stumped me, plus the little CLI hook I ended up with.
Twitch doesn't have an API key
If you came from an API like dev.to, where you generate a key and put it in a header, Twitch will
briefly confuse you. There is no “API key.” Twitch is pure OAuth: you
register an application, which gives you a Client ID and (sometimes) a
Client Secret, and you trade those for a token that the API actually
accepts. Two hosts are involved — https://id.twitch.tv/oauth2 for getting tokens,
and https://api.twitch.tv/helix for the API itself (Helix is the name of the current
API).
For server-side calls against public data — users, streams, games — you want an app access token, minted with the “client credentials” grant. That's the flow that needs a secret, and that's where the missing button comes in.
Creating the application
Head to dev.twitch.tv/console and click
Register Your Application. Three fields matter:
- Name — shown to users when they authorize; must be unique across Twitch.
- OAuth Redirect URLs — where Twitch sends users back after they log in.
It's required even if you only ever do server-to-server calls that never redirect anyone;
for local testing Twitch accepts
http://localhost. It must later match yourredirect_uriexactly. - Category — pick whatever fits (I used Game Integration); it doesn't affect API access.
Save, and you immediately get a Client ID — a public string that identifies your app. That part is painless. The secret is where it got interesting.
The secret button that wasn't there
I scrolled down expecting a “New Secret” button and found nothing. The cause was a radio button further up the form: Client Type, and mine was set to Public.
It actually makes sense once you say it out loud. “Public” clients are things that run where a user could read their own memory — a mobile app, a desktop binary, a JavaScript single-page app, a game-engine plugin. You can't keep a secret in any of those; ship it and it's extractable. So Twitch refuses to issue one. “Confidential” clients run somewhere the user can't see — a server, a backend, a script on a box you control — and those are the only ones allowed a secret.
Since I was calling the API from a server-side script, Confidential was the correct choice anyway. The fix:
- Open the app → set Client Type to Confidential → Save.
- A New Secret button now appears under the Client ID. Click it, confirm, and copy the secret immediately — it's shown once.
Minting an app access token
With a Confidential app's ID and secret, one POST gets you a token:
curl -X POST https://id.twitch.tv/oauth2/token \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "grant_type=client_credentials"
You get back an access_token and an expires_in — app tokens last a
long time (mine came back valid for roughly two months). From then on, every Helix request
carries two headers: the bearer token and the client id. Forget the
Client-Id header and you'll get a confusing 401 even though your token is fine:
curl https://api.twitch.tv/helix/games/top?first=3 \
-H "Authorization: Bearer YOUR_APP_TOKEN" \
-H "Client-Id: YOUR_CLIENT_ID"
Before trusting any of it, validate the token — GET id.twitch.tv/oauth2/validate
with an Authorization: OAuth <token> header echoes back the client id and expiry. If
that matches, you're wired correctly.
Wrapping it in a hook
Doing the token dance by hand on every call gets old, so I wrapped it in a small stdlib-only Python CLI. The two pieces that matter: mint-and-cache the token (so you're not re-minting every call), and a single Helix helper that attaches both headers.
import json, time, os, tempfile, urllib.parse, urllib.request
ID_BASE = "https://id.twitch.tv/oauth2"
HELIX = "https://api.twitch.tv/helix"
CACHE = os.path.join(tempfile.gettempdir(), "twitch_app_token.json")
def app_token(client_id, client_secret):
# reuse a cached token until it nears expiry
if os.path.exists(CACHE):
j = json.load(open(CACHE))
if j.get("_client_id") == client_id and j["_expires_at"] - time.time() > 300:
return j["access_token"]
body = urllib.parse.urlencode({"client_id": client_id,
"client_secret": client_secret, "grant_type": "client_credentials"}).encode()
j = json.loads(urllib.request.urlopen(ID_BASE + "/token", body).read())
j["_expires_at"] = time.time() + j["expires_in"]
j["_client_id"] = client_id
json.dump(j, open(CACHE, "w"))
return j["access_token"]
def helix(path, token, client_id, **params):
url = HELIX + path + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}", "Client-Id": client_id})
return json.loads(urllib.request.urlopen(req).read())
def is_live(login, token, client_id):
data = helix("/streams", token, client_id, user_login=login)["data"]
return bool(data) # empty list = offline
Hang an operations dictionary and a tiny argument parser off that, and the API becomes shell verbs:
python twitch_hook.py validate
python twitch_hook.py getUsers --login=ninja,pokimane
python twitch_hook.py isLive --login=ninja
python twitch_hook.py topGames --first=5
python twitch_hook.py searchChannels --query=arcomage --live_only=true
Every one of those is verified against the live API, not just lifted from docs — the
validate call is what told me the token and client id were a matched pair before I built
anything on top.
What an app token can and can't do
One honest limit, so you don't chase a wall like I chased the secret button: an app access token only reaches public data. Users, streams, games, clips, channel search — all fine. But anything tied to a specific user's private data — their follow list, their subscriptions, reading or sending chat, channel-point redemptions — needs a user access token, which means sending a real person through the authorization-code login flow (this is where that redirect URL finally earns its keep). Different token, different flow. If your script only reads public things, the app token is all you need and you never have to bother a user.
The takeaway
Twitch's API is approachable once you get past the one non-obvious wall: no secret without a Confidential client, and no app token without a secret. Set the client type correctly, mint a token, send both headers, and the whole Helix surface opens up — checking who's live, looking up games, searching channels, all from one line in a terminal.
The code in this article is released under the MIT License — Copyright © 2026 Trent Tompkins. Keep your client secret server-side and out of version control.