The Shoutout

Bonjour — this one is for you, table_basse_furtive. You asked how !apex connects to Twitch, and the honest answer was too long for chat.

So: what it does, how it authenticates, the two silent traps that cost me a live re-auth, and enough code to have the same command running on your channel in an afternoon. None of it is specific to my setup — it is Python and the official Twitch API, and it works the same on any channel.

Start here: a shoutout has two halves

This is the part I got wrong, and it is the reason the page exists.

half one

The card in my chat

A highlighted announcement. My viewers see it, it looks deliberate, and it is completely invisible to the person it is about.

half two

The shoutout on your side

Twitch's own endpoint. It fires channel.shoutout.receive on your channel — your Stream Manager activity feed, and your own shoutout alert if you have one.

I shipped half one alone and it felt finished. It was not: the streamers I was honouring never learned it had happened. Nearly every custom !so you see in a chat bot is that half only — half two needs a token carrying moderator:manage:shoutouts that belongs to you or one of your mods, and the hosted bots do not ask for it.

What "my own daemon" actually means

Not a hosted bot and nothing you sign up for. It is a long-running Python process on my own machine, started at login, that holds a Twitch user token and talks to two things:

  • Chat, over IRC — it reads every message and matches the ones starting with !.
  • Helix, the REST API — announcements, shoutouts, polls, predictions, stream markers, clips, channel-point redemptions.

One library covers both: pip install "twitchAPI>=4.5" (pyTwitchAPI). Python 3.11+.

Why mine rather than Nightbot's. A hosted bot posts as the bot. This posts as me — my token, my account — so the card carries my name, and Twitch accepts the broadcaster-level calls without a second account being involved at all.

The honest trade-off: it only works while my machine is awake. That costs me nothing, because it is the same machine running OBS. If that is not true for you, the same file runs unchanged on a small VPS.

The connection — the part you asked about

  1. Register an application at dev.twitch.tv/console. You get a Client ID and a Client Secret. Set the OAuth Redirect URL to exactly http://localhost:3000 — it has to match, character for character, what your code sends later. A mismatch shows as redirect_mismatch in the browser and says nothing more useful than that.
  2. Decide the scopes — the list below is everything !apex needs.
  3. Run the consent flow once. UserAuthenticator opens Twitch's Authorize screen; you click it; you get back an access token and a refresh token.
  4. Store both outside the repo — a gitignored .env is fine. The access token is a live credential for your account.
  5. Refresh, and write the new pair back. Access tokens last about four hours. The refresh token is rotated every time you use it, so if you refresh and do not save the new one, your next restart is simply logged out.
What it buys youScope
Read chat and post in itchat:read · chat:edit
The highlighted card (instead of a plain line)moderator:manage:announcements
The shoutout that reaches themmoderator:manage:shoutouts
Their display name and last categoryno scope — public read
Whether they are live right nowno scope — public read

Worth noticing: two of the four things the card says cost no permission at all. Their name, their category, their title and whether they are live are public reads. You could build a decent shoutout with chat scopes alone — you would just be building half one.

Two traps, and both of them are silent

1. The consent screen only asks for what your auth script lists. Adding a scope to the code that uses it changes nothing at all: the token comes back without it, your if scope in granted check quietly evaluates false, and the feature is simply off. No error, no log line, nothing. I keep two scope lists in two files and lost a full re-auth cycle to exactly this — while adding this exact shoutout scope.

2. A running daemon will overwrite your new token. It rotates its own refresh token and writes it back to the same file you just wrote to. Re-authorize while it is running and it clobbers your fresh, wider-scoped pair with its own narrower one, at a moment of its choosing. Stop the process completely before you re-auth — not a restart, fully down until the browser flow is finished.

And your .env cannot tell you what was granted. It holds a string, not a permission. The only thing that knows is https://id.twitch.tv/oauth2/validate — call it after every re-auth and read the scope list back. I now have a script that refuses to start the flow unless the scope is already in both lists, and verifies against that endpoint afterwards.

⚠️ Do the re-auth off camera. The redirect lands on localhost with an authorization code in the URL bar. That must never be on stream — I switch to a pause scene with no screen capture first.

The whole feature

This is the real shape of it, with my rotation and formatting stripped out:

async def apex(twitch, my_id, login, note=""):
    # 1. Who they are, and are they on right now. Both are PUBLIC reads —
    #    no scope, no permission, they work on the token you already have.
    user = None
    async for u in twitch.get_users(logins=[login]):
        user = u
        break
    if user is None:
        return "couldn't find that channel — check the spelling?"

    live = None
    async for s in twitch.get_streams(user_id=[user.id]):
        live = s
        break

    # 2. The half that reaches THEM. Best-effort ON PURPOSE: Twitch refuses
    #    this routinely and reasonably — you are not live, you shouted someone
    #    out in the last 2 minutes, the same person within the last 60. None of
    #    those should cost you the gesture you just made in front of chat.
    try:
        await twitch.send_a_shoutout(from_broadcaster_id=my_id,
                                     to_broadcaster_id=user.id,
                                     moderator_id=my_id)   # must match the token's user
    except Exception as e:
        print("native shoutout not sent:", e)   # and the card below still goes out

    # 3. The half YOUR chat sees. This one always runs.
    line = f"APEX — @{user.display_name}"
    if note:                                   # their own words beat anything generated
        line += f' · "{note}"'
    if live is not None:
        line += f' · LIVE RIGHT NOW: "{live.title}" · go watch them: twitch.tv/{login}'
    else:
        line += f" · show them some love: twitch.tv/{login}"
    await twitch.send_chat_announcement(my_id, my_id, line, "purple")

The order is deliberate. The native call goes first and is wrapped, so a refusal costs nothing but a log line — chat still gets its card, at the moment you meant it, every time.

Make it degrade instead of break

Every capability in my daemon is a flag read from the token at startup. If a scope is missing, that one feature is off and the log says which one — chat, and everything else, keeps running.

from twitchAPI.oauth import validate_token

# At startup, ask TWITCH what the token actually carries — never a config file
# that says what it should carry. Then switch each feature on from that.
info = await validate_token(access_token)
# 🔴 twitchAPI 4.x hands back AuthScope ENUMS here, not strings. Compare an enum
#    to "moderator:manage:shoutouts" and it is False forever — which is this
#    page's own warning, wearing a different hat. Normalize to the .value first.
granted = {getattr(s, "value", s) for s in (info.get("scopes") or [])}

shoutout_enabled = "moderator:manage:shoutouts"     in granted
announce_enabled = "moderator:manage:announcements" in granted

print("native shoutout:",
      "ENABLED" if shoutout_enabled else "off — grant moderator:manage:shoutouts")

Which means !apex has three working states rather than working-or-broken: both halves, card-only if the shoutout scope is missing, and a plain chat line if the announcement scope is missing too. A viewer never sees a command that does nothing.

The part that keeps it from sounding like a bot

The first version sent everybody the identical sentence. Two shoutouts in a row read as a mail merge — which is the exact opposite of the point. Four ingredients fixed it, in descending order of how much they matter:

  • Your own words. !apex @table_basse_furtive they carried my whole morning. Nothing a program generates beats a sentence you actually meant, so it is checked first and it outranks everything below.
  • Their stream title. "Day 194 of the 2026 grind" says who somebody is in a way "check them out" never will.
  • Live right now. "Go watch them" beats "go follow them", and it costs one extra public call to know which one is true.
  • Kinship. If their category or tags match mine, say so. A co-working streamer shouting out another co-working streamer should not sound like a stranger.

And the fallback openers rotate, so two shoutouts back to back never begin the same way. What comes out:

🦈 APEX — @table_basse_furtive · one of our own — same hours, same reef · 🔴 LIVE RIGHT NOW: "Hello, let's work together guys!" (Co-working & Studying) · go watch them: twitch.tv/table_basse_furtive

Mods can fire it too, not just me — they are usually the ones who notice a friend went live while I am buried in a problem.

The bug that will bite you

Twitch's Reply button prepends @name to the message text. A command typed as a reply therefore arrives as @you !apex @them, does not begin with !, and never runs. No error — the command simply does nothing.

It hit me on this exact command, and the bug report was "it was working earlier". It was. The earlier ones were not replies. It breaks every command the same way, for viewers as much as for you.

# Twitch's Reply button prepends "@name " to the message TEXT, so a command
# typed as a reply arrives as "@you !apex @them" and never matches.
# Strip leading mentions for DISPATCH ONLY — your chat overlay must still show
# what was really typed. Narrow on purpose: only mentions at the very start,
# and only when a "!" follows, so "look at !apex lol" mid-sentence stays inert.
REPLY_PREFIX = re.compile(r"^(?:@[^\s]+[,:]?\s+)+(?=!)")

cmd_text = REPLY_PREFIX.sub("", text) if text.startswith("@") else text
if not cmd_text.startswith("!"):
    return

If you would rather not run a daemon

All of these are real answers, and the first one costs nothing:

  • Twitch's own /shoutout name, typed in chat by you or a mod. That is half two, free, today, no code. No card and no words of your own — but the streamer actually gets notified, which is the half most bots are missing.
  • A Nightbot / StreamElements / Fossabot custom command. That is half one, and some of them can pull the target's last category into it. What you will not get from them is the native shoutout — nothing in the API forbids a bot from asking for moderator:manage:shoutouts, they simply do not, so check before you assume either way.
  • Both, with no code at all: a mod types /shoutout, the bot posts the card. Two actions instead of one, and honestly most of the value.
  • The daemon. Worth it when you want both halves in one command, your own sentence inside the card, and the rest of the surface — polls, markers, clips, channel points — on the same connection you already opened.

If you want it, I will send you the file and we can get your token authorized and a first shoutout firing on a call. The authorization is genuinely the only fiddly part — the command itself is the thirty lines above.

— Mahmut · twitch.tv/kinesinShips