Twitch Discord Webhook: Go-Live and YouTube Video Notifications
Set up a twitch discord webhook for go-live alerts and a YouTube new-video feed: EventSub relay in Node, RSS poller in Python, embeds with a Watch button.
On this page
- Three ways to get go-live alerts into Discord
- Design the embed before writing code
- YouTube: a Python poller for the channel RSS feed
- Twitch: an EventSub relay in Node
- No-code: Zapier, IFTTT and n8n
- Common errors
- FAQ
- Can Twitch send go-live notifications straight to a Discord webhook?
- How fast are YouTube notifications through the RSS feed?
- Can I add a “Watch” button without running a bot?
- Can the announcement update itself when the stream ends?
- Next steps
Neither Twitch nor YouTube will POST to a Discord webhook for you. Twitch EventSub delivers its own JSON to an HTTPS endpoint you own, YouTube publishes an RSS feed per channel, and Discord expects a specific message body at https://discord.com/api/webhooks/{id}/{token}. Something has to sit in the middle and translate.
This guide covers the three realistic ways to build that middle piece: a complete Python poller for YouTube uploads, a Node relay for Twitch EventSub, and the no-code route through Zapier, IFTTT or n8n. All three end with the same embed: a preview image, a role ping and a “Watch” link button that works on a plain webhook, no bot required.
Three ways to get go-live alerts into Discord
| Route | Delay | What you host | Best for |
|---|---|---|---|
| Twitch EventSub → your script → webhook | Seconds | A public HTTPS endpoint | Twitch go-live with title, category and preview |
| YouTube RSS feed → polling script | Your polling interval | A cron job on any always-on machine | New uploads, no API keys at all |
| Zapier / IFTTT / n8n cloud | Minutes | Nothing | Zero code, a few alerts per day |
EventSub is push-based: Twitch calls your URL the moment a stream starts, but you need a server with a valid certificate and you must verify signatures. RSS polling is the simplest thing that works for YouTube: no Google API project, no OAuth, just an HTTP GET every few minutes and a file that remembers which video ids you already announced. No-code tools are fine until you want custom embeds, dedupe logic or a button.
Whichever route you pick, the Discord side is identical, so design that part first.
Design the embed before writing code
Here is the payload both scripts below produce. Paste it into the visual builder on discord-webhook.com or send it with curl to see how it renders:
{
"username": "Stream Alerts",
"content": "<@&123456789012345678> 🔴 **Nova** just went live!",
"allowed_mentions": { "roles": ["123456789012345678"] },
"embeds": [
{
"title": "Elden Ring any% — road to sub 30",
"url": "https://www.twitch.tv/nova",
"description": "Playing **Elden Ring**",
"color": 9520895,
"image": {
"url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_nova-1280x720.jpg?t=1789668000"
},
"fields": [{ "name": "Started", "value": "<t:1789668000:R>", "inline": true }],
"footer": { "text": "twitch.tv" },
"timestamp": "2026-09-17T18:00:00.000Z"
}
],
"components": [
{
"type": 1,
"components": [
{ "type": 2, "style": 5, "label": "Watch on Twitch", "url": "https://www.twitch.tv/nova" }
]
}
]
}
curl -H "Content-Type: application/json" -d @payload.json \
"https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN?with_components=true"
What matters here:
imagevsthumbnail.imagerenders full width under the text;thumbnailis the small square on the right. Useimagefor the stream preview or video thumbnail andthumbnailfor a channel avatar. Both need a publichttpsURL.- The “Watch” button is
type: 2,style: 5, with aurland nocustom_id, inside an action row (type: 1). Discord only respectscomponentswhen the request URL has?with_components=true; without it the same JSON is accepted but the message arrives without the button. Ordinary webhooks can send link buttons and other non-interactive components; buttons that do something inside Discord need a bot, which is what the site’s bot integration adds. - The role ping
<@&roleId>only pings ifallowed_mentions.roleslists that id. Details in mentions and pings. <t:UNIX:R>shows “3 minutes ago” in every viewer’s timezone; the embedtimestampmust be ISO 8601.- Colors are decimal integers: Twitch purple
#9146FFis9520895, YouTube red#FF0000is16711680. - The
?t=cache-buster. Discord caches embed images by URL, and Twitch reuses the same preview URL for every stream. Without a changing query string your announcement shows last week’s frame.
YouTube: a Python poller for the channel RSS feed
Every YouTube channel has an Atom feed at https://www.youtube.com/feeds/videos.xml?channel_id=UC.... Copy the UC... id from the channel’s About panel (Share → Copy channel ID) or search the page source for channelId. The feed lists the latest uploads with title, link, publish time and a media:thumbnail URL, which is everything an embed needs.
The poller keeps a JSON file of announced video ids, seeds it on the first run so you do not spam the channel with old videos, and honours retry_after on 429. It uses requests; the Discord webhooks with Python guide covers the HTTP details, error handling and an async variant.
#!/usr/bin/env python3
import json
import os
import time
import xml.etree.ElementTree as ET
from pathlib import Path
import requests
CHANNEL_ID = os.environ["YT_CHANNEL_ID"]
WEBHOOK_URL = os.environ["DISCORD_WEBHOOK_URL"] # https://discord.com/api/webhooks/{id}/{token}
STATE_FILE = Path("seen_videos.json")
FEED_URL = f"https://www.youtube.com/feeds/videos.xml?channel_id={CHANNEL_ID}"
NS = {
"atom": "http://www.w3.org/2005/Atom",
"yt": "http://www.youtube.com/xml/schemas/2015",
"media": "http://search.yahoo.com/mrss/",
}
def fetch_entries() -> list[dict]:
resp = requests.get(FEED_URL, timeout=15)
resp.raise_for_status()
root = ET.fromstring(resp.content)
entries = []
for entry in root.findall("atom:entry", NS):
group = entry.find("media:group", NS)
thumb = group.find("media:thumbnail", NS) if group is not None else None
entries.append({
"id": entry.findtext("yt:videoId", namespaces=NS),
"title": entry.findtext("atom:title", namespaces=NS) or "New video",
"url": entry.find("atom:link", NS).get("href"),
"author": entry.findtext("atom:author/atom:name", namespaces=NS) or "YouTube",
"published": entry.findtext("atom:published", namespaces=NS),
"thumbnail": thumb.get("url") if thumb is not None else None,
})
return entries
def build_payload(video: dict) -> dict:
embed = {
"title": video["title"][:256],
"url": video["url"],
"color": 16711680,
"author": {"name": video["author"], "url": f"https://www.youtube.com/channel/{CHANNEL_ID}"},
"footer": {"text": "youtube.com"},
"timestamp": video["published"],
}
if video["thumbnail"]:
embed["image"] = {"url": video["thumbnail"]}
return {
"username": "YouTube Alerts",
"content": f"📺 New video from **{video['author']}**",
"embeds": [embed],
"components": [{
"type": 1,
"components": [{"type": 2, "style": 5, "label": "Watch", "url": video["url"]}],
}],
}
def send(payload: dict) -> None:
while True:
resp = requests.post(WEBHOOK_URL, params={"with_components": "true"}, json=payload, timeout=15)
if resp.status_code == 429:
time.sleep(resp.json().get("retry_after", 1))
continue
if resp.status_code >= 400:
raise RuntimeError(f"Discord returned {resp.status_code}: {resp.text}")
return
def main() -> None:
seen = set(json.loads(STATE_FILE.read_text())) if STATE_FILE.exists() else set()
first_run = not seen
for video in reversed(fetch_entries()): # oldest first so the channel reads in order
if video["id"] in seen:
continue
if not first_run:
send(build_payload(video))
seen.add(video["id"])
STATE_FILE.write_text(json.dumps(sorted(seen)))
if __name__ == "__main__":
main()
Run it once by hand to seed the state file, then put it on a schedule:
export YT_CHANNEL_ID=UCxxxxxxxxxxxxxxxxxxxxxx
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
python3 yt_poller.py
crontab -e # then add a line that runs it every 5 minutes:
*/5 * * * * cd /opt/yt-alerts && /usr/bin/python3 yt_poller.py >> poller.log 2>&1
Two things to know about the feed. It only shows the most recent uploads, so poll often enough that a busy channel cannot publish more than that between runs. And live streams and premieres appear as entries too; filter on the title if you only want uploads. If polling is too slow, YouTube also supports WebSub push for the same feed URL, which gives you the EventSub-style flow described next.
Twitch: an EventSub relay in Node
EventSub with the webhook transport works like this: you register an application, create a stream.online subscription for a broadcaster, and Twitch POSTs to your HTTPS callback. The first request is a verification challenge you must echo back as plain text; every event after that is signed with HMAC-SHA256 over the message id, timestamp and raw body. Your endpoint verifies the signature, answers 2xx quickly, fetches the live stream from Helix for the title and category, and posts the embed.
// twitch-alerts.js — Node 18+, npm install express
const express = require('express');
const crypto = require('node:crypto');
const {
TWITCH_CLIENT_ID,
TWITCH_APP_TOKEN, // app access token (client credentials grant)
TWITCH_EVENTSUB_SECRET, // the secret you pass when creating the subscription
DISCORD_WEBHOOK_URL,
} = process.env;
const app = express();
const seenMessages = new Set(); // Twitch retries deliveries; dedupe by message id
function verifySignature(req) {
const id = req.header('Twitch-Eventsub-Message-Id') ?? '';
const ts = req.header('Twitch-Eventsub-Message-Timestamp') ?? '';
const sig = req.header('Twitch-Eventsub-Message-Signature') ?? '';
const expected =
'sha256=' +
crypto
.createHmac('sha256', TWITCH_EVENTSUB_SECRET)
.update(id + ts)
.update(req.body) // raw Buffer, see express.raw below
.digest('hex');
return (
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
}
async function fetchStream(userId) {
const res = await fetch(`https://api.twitch.tv/helix/streams?user_id=${userId}`, {
headers: { 'Client-Id': TWITCH_CLIENT_ID, Authorization: `Bearer ${TWITCH_APP_TOKEN}` },
});
const { data } = await res.json();
return data?.[0] ?? null; // null if the stream already ended
}
async function postGoLive(event) {
const stream = await fetchStream(event.broadcaster_user_id);
const login = event.broadcaster_user_login;
const channelUrl = `https://www.twitch.tv/${login}`;
const preview = `https://static-cdn.jtvnw.net/previews-ttv/live_user_${login}-1280x720.jpg?t=${Date.now()}`;
const payload = {
username: 'Stream Alerts',
content: `🔴 **${event.broadcaster_user_name}** just went live!`,
embeds: [
{
title: (stream?.title || 'Live now').slice(0, 256),
url: channelUrl,
description: stream?.game_name ? `Playing **${stream.game_name}**` : undefined,
color: 9520895,
image: { url: preview },
fields: [
{
name: 'Started',
value: `<t:${Math.floor(Date.parse(event.started_at) / 1000)}:R>`,
inline: true,
},
],
footer: { text: 'twitch.tv' },
timestamp: event.started_at,
},
],
components: [
{
type: 1,
components: [{ type: 2, style: 5, label: 'Watch on Twitch', url: channelUrl }],
},
],
};
const res = await fetch(`${DISCORD_WEBHOOK_URL}?with_components=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Discord ${res.status}: ${await res.text()}`);
}
app.post('/twitch/eventsub', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifySignature(req)) return res.sendStatus(403);
const body = JSON.parse(req.body);
const type = req.header('Twitch-Eventsub-Message-Type');
if (type === 'webhook_callback_verification') {
return res.type('text/plain').send(body.challenge); // confirms the subscription
}
if (type === 'revocation') {
console.warn('Subscription revoked:', body.subscription.status);
return res.sendStatus(204);
}
const id = req.header('Twitch-Eventsub-Message-Id');
if (seenMessages.has(id)) return res.sendStatus(204);
seenMessages.add(id);
res.sendStatus(204); // answer Twitch first, then do the slow part
try {
if (body.subscription.type === 'stream.online') await postGoLive(body.event);
} catch (err) {
console.error(err);
}
});
app.listen(8080);
Deploy this behind a reverse proxy with a real certificate; Twitch only calls https:// callbacks on port 443. For local development, expose port 8080 through a tunnel service. Get an app access token with the client credentials grant at https://id.twitch.tv/oauth2/token, look up the broadcaster id with GET https://api.twitch.tv/helix/users?login=nova, then create the subscription:
curl -X POST "https://api.twitch.tv/helix/eventsub/subscriptions" \
-H "Client-Id: $TWITCH_CLIENT_ID" \
-H "Authorization: Bearer $TWITCH_APP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "stream.online",
"version": "1",
"condition": { "broadcaster_user_id": "141981764" },
"transport": {
"method": "webhook",
"callback": "https://alerts.example.com/twitch/eventsub",
"secret": "'"$TWITCH_EVENTSUB_SECRET"'"
}
}'
The subscription stays in webhook_callback_verification_pending until your endpoint echoes the challenge, then flips to enabled. The Twitch CLI can fire a fake stream.online at your URL signed with your secret, so you can test the whole chain without waiting for a real stream.
Two extensions worth knowing. Post with ?wait=true, store the returned id, subscribe to stream.offline as well, and PATCH .../messages/{message_id} when the stream ends, so the announcement turns into “stream over” instead of going stale. And if you would rather not expose a public URL, EventSub also has a WebSocket transport where your script connects out to Twitch; the Discord half stays the same.
No-code: Zapier, IFTTT and n8n
If you only need a couple of alerts a day and no custom logic, skip the code:
- Zapier: a Twitch or YouTube trigger, then a “Webhooks by Zapier” POST action with the Discord URL,
Content-Type: application/jsonand a JSON body built from the trigger fields. Append?with_components=trueto the URL if you include the button. - IFTTT: the YouTube “new public video” trigger plus the Webhooks “make a web request” action with the same JSON body.
- n8n: an RSS Feed Trigger node pointed at the YouTube feed URL, then an HTTP Request node posting the embed. Because n8n is self-hostable, it is the closest no-code equivalent of the Python poller.
Working examples and the tradeoffs are in Discord webhooks with Zapier, Make and n8n.
Common errors
- The message arrives without the button, or returns 400. No button: you sent it without
?with_components=true, so Discord ignoredcomponents. 400: the button has acustom_idand a style other than 5. Plain webhooks accept link buttons only. - The embed renders without an image, or with a stale one. The URL is not public
https, or Discord cached an earlier fetch of the same URL. Open it in a private browser window, and add a changing query string as in the examples. {"message":"Unknown Webhook","code":10015}. The id or token is wrong, or someone deleted the webhook. Create a new one in Server Settings → Integrations → Webhooks and update your environment variables.- 429 Too Many Requests. Read
retry_afterfrom the body and sleep that long before retrying, as the Pythonsend()does. Bursts of alerts are where this bites, see webhook rate limits. - Duplicate go-live posts. EventSub retries deliveries it did not get a 2xx for, so dedupe by
Twitch-Eventsub-Message-Id. For the RSS poller, duplicates mean the state file is not persisted, typical in a container without a mounted volume. - Twitch subscription never becomes
enabled. The callback is not reachable overhttps, the challenge was echoed as JSON instead of plain text, or the secret in your environment differs from the one in the subscription. - 400 because of the
username. Override names cannot contain “clyde” or “discord”, so “Discord Alerts” fails; “Stream Alerts” is fine.
FAQ
Can Twitch send go-live notifications straight to a Discord webhook?
No. EventSub posts Twitch’s own JSON shape and verifies your endpoint with a challenge, while a Discord webhook expects a message body. You need a small relay that receives the Twitch event and builds the Discord payload, or a no-code tool doing the same thing.
How fast are YouTube notifications through the RSS feed?
As fast as your polling interval plus however long YouTube takes to update the feed, so usually a few minutes after upload. For near-instant delivery subscribe to the same feed via WebSub and handle the push like the Twitch relay does.
Can I add a “Watch” button without running a bot?
Yes. A link button (type: 2, style: 5, url) is a non-interactive component, so a regular webhook may send it as long as the URL includes ?with_components=true. Only buttons that trigger actions inside Discord need a bot.
Can the announcement update itself when the stream ends?
Yes. Post with ?wait=true to get the message id, store it, then PATCH https://discord.com/api/webhooks/{id}/{token}/messages/{message_id} on stream.offline with the new content. See editing and deleting webhook messages.
Next steps
Design the embed once in the Discord Webhook builder, export it as JSON or Python, and drop it into whichever relay fits your setup. The same pattern covers any other event source: see notification patterns for webhooks, the Python webhook guide for the HTTP details, and Zapier, Make and n8n when you would rather not host anything.