Twitch Discord Webhook: уведомления о стримах и видео YouTube
Уведомления в Discord о старте стрима на Twitch и новых видео YouTube: twitch discord webhook через EventSub на Node, RSS-поллер на Python, embed с кнопкой.
На этой странице
- Три способа получать уведомления о стримах в Discord
- Сначала спроектируйте embed
- YouTube: поллер RSS-ленты канала на Python
- Twitch: ретранслятор EventSub на Node
- Без кода: Zapier, IFTTT и n8n
- Частые ошибки
- FAQ
- Может ли Twitch слать уведомления о старте стрима прямо в вебхук Discord?
- Насколько быстро приходят уведомления YouTube через RSS?
- Можно ли добавить кнопку «Смотреть» без бота?
- Может ли анонс сам обновиться, когда стрим закончился?
- Что дальше
Ни Twitch, ни YouTube не умеют сами отправлять POST в вебхук Discord. Twitch EventSub шлёт свой JSON на ваш HTTPS-эндпоинт, YouTube отдаёт RSS-ленту для каждого канала, а Discord ждёт вполне конкретное тело сообщения на https://discord.com/api/webhooks/{id}/{token}. Между ними нужна прослойка, которая переводит одно в другое.
В этой статье — три реальных способа её собрать: полный поллер RSS-ленты YouTube на Python, ретранслятор Twitch EventSub на Node и вариант без кода через Zapier, IFTTT или n8n. Во всех трёх на выходе один и тот же embed: превью, пинг роли и кнопка-ссылка «Смотреть», которая работает на обычном вебхуке без бота.
Три способа получать уведомления о стримах в Discord
| Способ | Задержка | Что хостить | Для чего |
|---|---|---|---|
| Twitch EventSub → ваш скрипт → вебхук | Секунды | Публичный HTTPS-эндпоинт | Старт стрима на Twitch с названием, категорией и превью |
| RSS-лента YouTube → скрипт по расписанию | Интервал опроса | Cron на любой постоянно работающей машине | Новые видео без единого API-ключа |
| Облачные Zapier / IFTTT / n8n | Минуты | Ничего | Ноль кода, пара уведомлений в день |
EventSub работает по push-модели: Twitch дёргает ваш URL в момент старта стрима, но нужен сервер с валидным сертификатом и проверка подписи. Опрос RSS — самое простое, что работает для YouTube: без проекта в Google API, без OAuth, только HTTP GET раз в несколько минут и файл, где записано, какие id видео уже анонсированы. No-code-сервисы хороши ровно до момента, когда захочется свой embed, защиту от дублей или кнопку.
Какой бы путь вы ни выбрали, сторона Discord одинакова, поэтому начните с неё.
Сначала спроектируйте embed
Вот payload, который собирают оба скрипта ниже. Вставьте его в визуальный конструктор на discord-webhook.com или отправьте через curl, чтобы увидеть, как он выглядит:
{
"username": "Stream Alerts",
"content": "<@&123456789012345678> 🔴 **Nova** в эфире!",
"allowed_mentions": { "roles": ["123456789012345678"] },
"embeds": [
{
"title": "Elden Ring any% — идём на саб-30",
"url": "https://www.twitch.tv/nova",
"description": "Играет в **Elden Ring**",
"color": 9520895,
"image": {
"url": "https://static-cdn.jtvnw.net/previews-ttv/live_user_nova-1280x720.jpg?t=1789668000"
},
"fields": [{ "name": "Начало", "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": "Смотреть на 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"
На что обратить внимание:
imageиthumbnail.imageрисуется на всю ширину под текстом,thumbnail— маленький квадрат справа. Для превью стрима или обложки видео беритеimage, аthumbnailоставьте под аватар канала. Обоим нужен публичныйhttps-URL.- Кнопка «Смотреть» — это
type: 2,style: 5, сurlи безcustom_id, внутри action row (type: 1). Discord учитываетcomponents, только если в URL запроса есть?with_components=true; без него тот же JSON примется, но сообщение придёт без кнопки. Обычный вебхук может слать кнопки-ссылки и другие неинтерактивные компоненты; кнопки, которые что-то делают внутри Discord, требуют бота — именно его даёт интеграция с ботом на сайте. - Пинг роли
<@&roleId>сработает, только если этот id перечислен вallowed_mentions.roles. Подробнее — в статье про упоминания и пинги. <t:UNIX:R>показывает «3 минуты назад» в часовом поясе каждого читателя;timestampв embed должен быть в ISO 8601.- Цвета — десятичные числа: фиолетовый Twitch
#9146FF— это9520895, красный YouTube#FF0000—16711680. - Параметр
?t=против кеша. Discord кеширует картинки embed по URL, а Twitch отдаёт превью каждого стрима по одному и тому же адресу. Без меняющегося query-параметра анонс покажет кадр с прошлой недели.
YouTube: поллер RSS-ленты канала на Python
У каждого канала YouTube есть Atom-лента по адресу https://www.youtube.com/feeds/videos.xml?channel_id=UC.... Идентификатор UC... берётся на вкладке «О канале» (Поделиться → Копировать ID канала) или из исходника страницы по слову channelId. В ленте есть последние загрузки с названием, ссылкой, временем публикации и media:thumbnail — всё, что нужно для embed.
Поллер хранит JSON-файл с id уже анонсированных видео, при первом запуске просто заполняет его, чтобы не завалить канал старыми роликами, и уважает retry_after при 429. Используется requests; детали HTTP, обработка ошибок и async-вариант — в гайде вебхуки Discord на Python.
#!/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 "Новое видео",
"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"📺 Новое видео от **{video['author']}**",
"embeds": [embed],
"components": [{
"type": 1,
"components": [{"type": 2, "style": 5, "label": "Смотреть", "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 вернул {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()): # от старых к новым, чтобы в канале был порядок
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()
Запустите один раз вручную, чтобы заполнить файл состояния, затем поставьте в расписание:
export YT_CHANNEL_ID=UCxxxxxxxxxxxxxxxxxxxxxx
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN"
python3 yt_poller.py
crontab -e # и добавьте строку с запуском каждые 5 минут:
*/5 * * * * cd /opt/yt-alerts && /usr/bin/python3 yt_poller.py >> poller.log 2>&1
Две особенности ленты. В ней только последние загрузки, поэтому опрашивайте достаточно часто, чтобы активный канал не успел выложить больше между двумя запусками. Кроме того, трансляции и премьеры тоже попадают в ленту как записи; если нужны только загрузки — фильтруйте по названию. Если опрос слишком медленный, YouTube поддерживает push-уведомления WebSub для того же URL ленты, и получится схема как у Twitch ниже.
Twitch: ретранслятор EventSub на Node
EventSub с транспортом webhook устроен так: вы регистрируете приложение, создаёте подписку stream.online на конкретного стримера, и Twitch делает POST на ваш HTTPS-коллбэк. Первый запрос — проверочный challenge, который нужно вернуть обратно как обычный текст; каждое следующее событие подписано HMAC-SHA256 по id сообщения, timestamp и сырому телу. Эндпоинт проверяет подпись, быстро отвечает 2xx, забирает из Helix название и категорию стрима и отправляет 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, // секрет, который вы передаёте при создании подписки
DISCORD_WEBHOOK_URL,
} = process.env;
const app = express();
const seenMessages = new Set(); // Twitch повторяет доставку; отсекаем дубли по 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) // сырой Buffer, см. express.raw ниже
.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, если стрим уже закончился
}
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}** в эфире!`,
embeds: [
{
title: (stream?.title || 'Стрим начался').slice(0, 256),
url: channelUrl,
description: stream?.game_name ? `Играет в **${stream.game_name}**` : undefined,
color: 9520895,
image: { url: preview },
fields: [
{
name: 'Начало',
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: 'Смотреть на 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); // подтверждаем подписку
}
if (type === 'revocation') {
console.warn('Подписка отозвана:', 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); // сначала отвечаем Twitch, потом делаем медленную часть
try {
if (body.subscription.type === 'stream.online') await postGoLive(body.event);
} catch (err) {
console.error(err);
}
});
app.listen(8080);
Разворачивайте это за reverse proxy с настоящим сертификатом: Twitch вызывает только https://-коллбэки на порту 443. Для локальной разработки пробросьте порт 8080 через туннель. Получите app access token по client credentials на https://id.twitch.tv/oauth2/token, узнайте id стримера через GET https://api.twitch.tv/helix/users?login=nova и создайте подписку:
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"'"
}
}'
Подписка висит в статусе webhook_callback_verification_pending, пока эндпоинт не вернёт challenge, после чего переходит в enabled. Twitch CLI умеет отправить на ваш URL поддельный stream.online, подписанный вашим секретом, — так можно проверить всю цепочку, не дожидаясь настоящего стрима.
Два полезных расширения. Отправляйте с ?wait=true, сохраняйте полученный id, подпишитесь заодно на stream.offline и делайте PATCH .../messages/{message_id}, когда стрим закончился, — анонс превратится в «стрим завершён» вместо того, чтобы устареть. А если публичный URL светить не хочется, у EventSub есть транспорт WebSocket, где ваш скрипт сам подключается к Twitch; сторона Discord не меняется.
Без кода: Zapier, IFTTT и n8n
Если нужна пара уведомлений в день и никакой особой логики, код можно пропустить:
- Zapier: триггер Twitch или YouTube, затем действие «Webhooks by Zapier» с POST на URL Discord, заголовком
Content-Type: application/jsonи JSON-телом из полей триггера. Если добавляете кнопку, допишите к URL?with_components=true. - IFTTT: триггер YouTube «новое публичное видео» плюс действие Webhooks «make a web request» с тем же JSON-телом.
- n8n: нода RSS Feed Trigger с URL ленты YouTube, затем нода HTTP Request, отправляющая embed. n8n можно хостить у себя, так что это ближайший no-code-аналог Python-поллера.
Рабочие примеры и компромиссы — в статье вебхуки Discord через Zapier, Make и n8n.
Частые ошибки
- Сообщение пришло без кнопки или вернулся 400. Нет кнопки: запрос ушёл без
?with_components=true, и Discord проигнорировалcomponents. 400: у кнопки естьcustom_idи стиль не 5. Обычный вебхук принимает только кнопки-ссылки. - Embed без картинки или со старой картинкой. URL не публичный
https, либо Discord закешировал предыдущую загрузку по тому же адресу. Откройте URL в приватном окне браузера и добавьте меняющийся query-параметр, как в примерах. {"message":"Unknown Webhook","code":10015}. Неверный id или токен, либо вебхук удалили. Создайте новый в «Настройки сервера → Интеграции → Вебхуки» и обновите переменные окружения.- 429 Too Many Requests. Прочитайте
retry_afterиз тела ответа и подождите столько перед повтором — так делаетsend()в Python. Всплески уведомлений — как раз тот случай, когда это бьёт; см. лимиты вебхуков. - Анонс стрима приходит дважды. EventSub повторяет доставку, если не получил 2xx, поэтому отсекайте дубли по
Twitch-Eventsub-Message-Id. У RSS-поллера дубли значат, что файл состояния не сохраняется — типично для контейнера без примонтированного тома. - Подписка Twitch не становится
enabled. Коллбэк недоступен поhttps, challenge вернули как JSON вместо plain text, либо секрет в окружении отличается от того, что указан в подписке. - 400 из-за
username. В переопределённом имени нельзя использовать «clyde» и «discord», поэтому «Discord Alerts» не пройдёт, а «Stream Alerts» — пройдёт.
FAQ
Может ли Twitch слать уведомления о старте стрима прямо в вебхук Discord?
Нет. EventSub отправляет JSON в формате Twitch и проверяет ваш эндпоинт через challenge, а вебхук Discord ждёт тело сообщения. Нужен небольшой ретранслятор, который принимает событие Twitch и собирает payload для Discord, либо no-code-сервис, делающий то же самое.
Насколько быстро приходят уведомления YouTube через RSS?
Задержка равна интервалу опроса плюс время, за которое YouTube обновляет ленту, — обычно несколько минут после загрузки. Если нужно почти мгновенно, подпишитесь на ту же ленту через WebSub и обрабатывайте push так же, как ретранслятор Twitch.
Можно ли добавить кнопку «Смотреть» без бота?
Да. Кнопка-ссылка (type: 2, style: 5, url) — неинтерактивный компонент, поэтому обычный вебхук может её отправить, если в URL есть ?with_components=true. Бот нужен только кнопкам, которые запускают действия внутри Discord.
Может ли анонс сам обновиться, когда стрим закончился?
Да. Отправьте с ?wait=true, чтобы получить id сообщения, сохраните его, а по stream.offline сделайте PATCH https://discord.com/api/webhooks/{id}/{token}/messages/{message_id} с новым содержимым. Подробности — в статье про редактирование и удаление сообщений вебхука.
Что дальше
Соберите embed один раз в конструкторе Discord Webhook, экспортируйте как JSON или Python и вставьте в тот ретранслятор, который подходит вашей инфраструктуре. Та же схема работает для любого другого источника событий: смотрите паттерны уведомлений через вебхуки, гайд по вебхукам на Python с деталями HTTP и Zapier, Make и n8n, если хостить ничего не хочется.