RE:NODE

Вебхуки Discord: как создать, отправить, изменить и защитить

Автор Команда RE:NODE
16 мин чтения


Вебхук Discord - это URL, который публикует сообщения в один канал. Вы создаёте его в настройках канала, отправляете ему HTTP-запрос POST с небольшим JSON-телом, и в канале появляется сообщение под тем именем и аватаром, которые вы выбрали. Бот-аккаунт не нужен, токен регистрировать не нужно, библиотеку ставить не нужно, и держать запущенный процесс тоже не нужно. Поэтому вебхук подходит для односторонних сообщений от программ - деплой завершён, форма отправлена, backup не удался, вышло новое видео - и не подходит для всего, что должно слушать и отвечать.

Всё описанное ниже работает из любого языка и с любой машины, которая умеет делать HTTPS-запросы. Ошибаются обычно в другом: URL - это единственный секрет, и он никогда не истекает; несколько параметров запроса (wait, thread_id) полностью меняют поведение; а вебхук, к которому применили ограничение частоты запросов, сам сообщает, сколько ждать, если прочитать ответ. Если вам нужно живое сообщение о состоянии игрового сервера, это целиком разобрано в статье Discord webhooks for game server alerts; этот материал - общий справочник, на который она опирается.

Что такое вебхук и почему весь он - это URL#

Вебхук принадлежит каналу. У него есть числовой id и длинный случайный токен, а URL - это просто они оба, соединённые вместе:

code
https://discord.com/api/webhooks/1290000000000000000/Xy7k...the-token...Qp2

Любой, кто может отправить на этот URL запрос с правильным телом, может писать в канал. Второго фактора нет, входа в систему нет, область действия - только этот канал, срока действия нет. URL - это и есть пароль, и он действует, пока кто-нибудь не удалит вебхук. Именно из этого факта вытекает большая часть советов по безопасности в конце статьи.

Сообщение, отправленное через вебхук, показывает имя и аватар вебхука (или те, что вы переопределили для конкретного сообщения), а рядом с именем - небольшую метку, показывающую, что сообщение автоматическое. Вебхук не является участником сервера, не появляется в списке участников и не имеет ролей. Его нельзя упомянуть, ему нельзя отправить личное сообщение, и он ничего не видит в канале - в том числе собственных сообщений, кроме как через специальные эндпоинты, которые получают, изменяют или удаляют их.

Вебхуки встречаются и в двух местах, где вы можете их не ожидать:

  • Подписка на канал объявлений. Когда сервер подписывается на канал объявлений другого сервера, Discord создаёт в канале назначения вебхук, который доставляет кросс-посты. Он отображается в списке интеграций, как и любой другой.
  • Интеграции, которые пишут от вашего имени. Многие сервисы (системы CI, инструменты мониторинга, сам GitHub) просят у вас URL вебхука Discord, а не установку бота, именно потому, что вебхуку не нужны права за пределами одного канала.

Создание вебхука в настройках канала#

Вам нужно право Manage Webhooks в этом канале; у владельцев и администраторов сервера оно есть по умолчанию.

  1. Наведите курсор на канал и нажмите на значок шестерёнки (Edit Channel) либо щёлкните канал правой кнопкой и выберите Edit Channel.
  2. Откройте Integrations, затем Webhooks.
  3. Нажмите New Webhook. Discord сразу создаст вебхук со сгенерированным именем.
  4. Нажмите на него, чтобы развернуть, задайте имя, при желании загрузите аватар и проверьте, что в выпадающем списке выбран нужный канал.
  5. Нажмите Copy Webhook URL, а затем Save Changes, если что-то меняли.

Server Settings, Integrations, Webhooks показывает то же самое для всего сервера: каждый вебхук в каждом канале и того, кто его создал. Эта страница нужна для аудита и наведения порядка, а журнал аудита сервера фиксирует создание, изменение и удаление вебхуков.

На момент написания действуют несколько практических ограничений: в канале может быть до 15 вебхуков, имя вебхука должно содержать от 1 до 80 символов, а имена со словами discord или clyde отклоняются. Discord время от времени меняет эти значения, так что если при создании возникает ошибка о лимитах, смотрите документацию для разработчиков.

Называйте вебхук по источнику - CI, Backups, Shop orders - а не оставляйте «Captain Hook» или другое сгенерированное имя. Имя становится автором по умолчанию для каждого сообщения и меткой, которую вы будете искать в списке интеграций, когда что-то пойдёт не так.

Первое сообщение через curl#

Минимальный корректный запрос - это POST с JSON-телом, в котором есть content:

bash
$ curl -X POST "$WEBHOOK_URL" \    -H "Content-Type: application/json" \    -d '{"content": "Deploy finished on main."}'

При успехе Discord отвечает 204 No Content с пустым телом. Это не ошибка, и пустой ответ не случаен: по умолчанию API не дожидается сохранения сообщения, прежде чем ответить. Добавьте ?wait=true, и вы получите 200 OK с полным объектом сообщения, включая его id, который понадобится, чтобы позже изменить или удалить сообщение.

Поля, которые пригодятся почти в каждом сообщении:

ПолеЧто делает
contentТекст сообщения, до 2000 символов, разметка Discord
usernameПереопределяет имя вебхука только для этого сообщения
avatar_urlПереопределяет аватар, прямая ссылка на изображение
embedsДо 10 объектов embed (карточки с заголовком и полями)
allowed_mentionsОпределяет, какие упоминания действительно уведомляют
flags4096 отправляет сообщение без уведомлений
ttsЗачитывает сообщение вслух тем, у кого включён TTS

Должно присутствовать хотя бы одно из полей content, embeds, файл или опрос, иначе запрос завершится ошибкой 400. Полный пример с переопределённым именем и отключёнными упоминаниями:

bash
$ curl -X POST "$WEBHOOK_URL?wait=true" \    -H "Content-Type: application/json" \    -d '{      "username": "Shop",      "avatar_url": "https://example.com/shop-icon.png",      "content": "New order #1042 - 2 items, paid by card",      "allowed_mentions": {"parse": []}    }'

avatar_url должен вести прямо на файл изображения, который Discord может скачать: страница, содержащая картинку, не подойдёт, а ссылка, требующая входа, молча заменится аватаром по умолчанию. username и avatar_url действуют только для одного сообщения; сохранённые имя и аватар вебхука не меняются.

В содержимом работает обычная разметка Discord: **bold**, встроенный код, блоки кода, ссылки с подписью вроде [changelog](https://example.com), упоминания пользователей как <@USER_ID>, упоминания ролей как <@&ROLE_ID> и метки времени вроде <t:1760000000:R>, которые отображаются как «3 часа назад» в часовом поясе каждого читателя. Будет ли упоминание действительно уведомлять, решает allowed_mentions. Пустой массив parse, как выше, означает, что не уведомляется никто, и это безопасный вариант для всего, что пересылает чужой текст. Чтобы упомянуть одну конкретную роль и никого больше, перечислите её явно:

json
{  "content": "<@&123456789012345678> the nightly build failed",  "allowed_mentions": { "parse": [], "roles": ["123456789012345678"] }}

Если что-то не так, Discord отвечает 400 и JSON-телом, где названо отклонённое поле. Прочитайте его, прежде чем менять что-либо ещё: оно почти всегда конкретнее, чем вы ожидаете.

Отправка из Python и JavaScript#

Для вебхука не нужна никакая библиотека Discord. Подойдёт любой HTTP-клиент, а кода так мало, что зависимость не обязательна.

Python с requests:

python
import osimport requestsurl = os.environ["WEBHOOK_URL"]payload = {    "username": "Backups",    "content": "Nightly backup finished: 412 MB in 38 s",    "allowed_mentions": {"parse": []},}r = requests.post(url, json=payload, params={"wait": "true"}, timeout=10)r.raise_for_status()print("message id:", r.json()["id"])

Если проект уже использует discord.py 2.x, в нём есть синхронный клиент вебхуков, которому не нужны ни бот, ни цикл событий:

python
from discord import SyncWebhookhook = SyncWebhook.from_url(os.environ["WEBHOOK_URL"])msg = hook.send("Deploy finished", username="CI", wait=True)print(msg.id)

В JavaScript на Node 18 и новее fetch встроен, так что простой вариант обходится без пакетов:

javascript
const res = await fetch(`${process.env.WEBHOOK_URL}?wait=true`, {  method: "POST",  headers: { "Content-Type": "application/json" },  body: JSON.stringify({    username: "CI",    content: "Build 512 passed",    allowed_mentions: { parse: [] },  }),});if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);const message = await res.json();console.log(message.id);

В discord.js есть эквивалент - WebhookClient, который создаётся через new WebhookClient({ url }) и используется с send(). Он сам обрабатывает ограничения частоты запросов, что полезно, если скрипт отправляет много сообщений.

На любом языке храните URL в переменной окружения, а не в исходном коде. Остальной код - это всегда три шага: собрать словарь, отправить его как JSON, проверить код статуса. О том, где значение должно храниться на сервере, рассказывает статья Environment variables and secrets.

Embed, файлы и лимиты, которые приводят к 400#

Обычный content подходит для однострочных уведомлений. Для всего, у чего есть структура - заказ с несколькими полями, сборка с коммитом и автором, отчёт с цветом, означающим хорошо или плохо, - используйте embed:

json
{  "username": "CI",  "embeds": [{    "title": "Build 512 passed",    "url": "https://example.com/builds/512",    "description": "main - fix login redirect",    "color": 3066993,    "fields": [      { "name": "Duration", "value": "2m 14s", "inline": true },      { "name": "Tests", "value": "418 passed", "inline": true }    ],    "footer": { "text": "runner-02" },    "timestamp": "2026-10-15T09:30:00.000Z"  }]}

На двух деталях спотыкаются почти все. color - это десятичное целое, поэтому цвет #2ECC71 отправляется как 3066993, а строка в этом поле даст 400. А timestamp должен быть в формате ISO 8601, и Discord затем показывает его в местном времени каждого читателя.

Лимиты применяются строго, а не обрезаются: 2000 символов content, 10 embed на сообщение, 256 символов в заголовке embed, 4096 в описании, 25 полей с именами до 256 символов и значениями до 1024 символов, и 6000 символов суммарно во всех embed одного сообщения. В статье про статус сервера есть полная таблица лимитов и описание того, что происходит, когда stack trace выходит за её пределы. Обрезайте текст на своей стороне до отправки.

Файлы отправляются как multipart/form-data, а не JSON. JSON-тело переносится в поле формы payload_json, а каждый файл - это поле с именем files[0], files[1] и так далее:

bash
$ curl -X POST "$WEBHOOK_URL" \    -F 'payload_json={"content": "Error log from the failed run"}' \    -F 'files[0]=@build.log'

Ограничение на размер загрузки зависит от уровня буста сервера, и Discord менял его не раз; на сервере без буста сейчас это 10 МБ на сообщение. Логи большего размера лучше хранить в объектном хранилище или системе логов, а в сообщении оставить ссылку.

Две конечные точки совместимости избавляют от необходимости писать адаптер. Если добавить /slack к URL вебхука, он принимает данные в формате Slack, а /github принимает формат вебхуков GitHub, так что можно вставить https://discord.com/api/webhooks/ID/TOKEN/github в настройки вебхука репозитория (тип содержимого application/json) и получать уведомления о коммитах и pull request без единой строки кода.

wait=true, треды и форум-каналы#

Строка запроса в URL отправки меняет поведение, и основную работу выполняют два параметра.

ПараметрЭффект
wait=trueОтветить 200 с сохранённым сообщением, а не 204
thread_id=IDОтправить в существующий тред канала вебхука

Используйте wait=true всякий раз, когда собираетесь снова обращаться к сообщению или хотите убедиться, что оно действительно сохранено, прежде чем скрипт пойдёт дальше. Без него 204 говорит лишь о том, что запрос принят, и ни о чём больше.

thread_id позволяет одному вебхуку писать во много тредов. Тред должен принадлежать каналу вебхука: вебхук в #builds может писать в любой тред под #builds, но не в тред в другом месте. Распространённый приём - один тред на релиз или на инцидент, с публикацией всех связанных сообщений в него, чтобы родительский канал оставался читаемым.

В форум-каналах и медиаканалах нет обычной области сообщений, есть только посты, поэтому вебхук должен указать, какой пост имеется в виду. Либо передайте thread_id, чтобы ответить в существующем посте, либо отправьте в теле thread_name, чтобы создать новый пост с вашим сообщением в качестве первого:

bash
$ curl -X POST "$WEBHOOK_URL?wait=true" \    -H "Content-Type: application/json" \    -d '{"thread_name": "Incident 2026-10-15", "content": "API returning 502 since 09:12"}'

Ответ на этот запрос содержит сообщение, а его channel_id - это id нового треда; сохраните его, если будете отправлять продолжения. Запрос к вебхуку форума без thread_id и без thread_name завершается ошибкой 400. Если форум требует теги, applied_tags принимает список id тегов.

Изменение, удаление и управление сообщениями#

Вебхук может изменять только те сообщения, которые отправил сам, и обращается к ним по id под своим URL:

bash
# Read a message the webhook sent$ curl "$WEBHOOK_URL/messages/$MESSAGE_ID"# Edit it - only the fields you send are changed$ curl -X PATCH "$WEBHOOK_URL/messages/$MESSAGE_ID" \    -H "Content-Type: application/json" \    -d '{"content": "Deploy finished on main (rolled back 09:40)"}'# Delete it$ curl -X DELETE "$WEBHOOK_URL/messages/$MESSAGE_ID"

Если сообщение находится в треде, добавьте ?thread_id=THREAD_ID и к каждому из этих URL, иначе Discord будет искать его в родительском канале и вернёт 404. При редактировании имя автора и аватар остаются прежними; username и avatar_url при редактировании не принимаются. Чтобы убрать embed, отправьте "embeds": []; чтобы убрать текст, отправьте "content": "" (в сообщении всё равно должно что-то остаться).

Редактирование никого не уведомляет. Поэтому изменение на месте - правильный вариант для всего, что меняется многократно: строки прогресса, длины очереди, живой карточки статуса, а новое сообщение - правильный вариант для событий, которые люди должны заметить. Статья про статус сервера строит небольшой конечный автомат именно на этом.

Сам вебхук управляется через тот же URL:

  • GET $WEBHOOK_URL возвращает объект вебхука: его имя, id канала, id сервера и хеш аватара. Если запрос выполнен только с токеном, пользователь, создавший вебхук, в ответ не попадает. Это дешёвый способ проверить, что сохранённый URL ещё работает.
  • PATCH $WEBHOOK_URL с name и (или) avatar меняет сохранённые имя и аватар. avatar - это data URI (data:image/png;base64,...), а не ссылка. Для переноса вебхука в другой канал нужен токен бота или пользователя, а не токен вебхука.
  • DELETE $WEBHOOK_URL удаляет вебхук. После этого любой запрос к URL возвращает 404, навсегда.

Последний пункт важен: любой, у кого есть URL, может удалить вебхук. Это полезно, когда вы нашли утёкший URL в чужом paste, и досадно, когда небрежный скрипт случайно его выполнит.

Ограничения частоты и ответ 429#

Discord ограничивает частоту запросов на каждом маршруте, включая вебхуки. Практический потолок для одного вебхука - порядка пяти запросов за две секунды, а канал принимает примерно тридцать сообщений в минуту независимо от того, сколько вебхуков в него пишут. Эти цифры не опубликованы как фиксированные и меняются, поэтому не стоит зашивать в код паузу и считать вопрос решённым. Читайте ответ.

При превышении ответом будет 429 Too Many Requests с заголовками и телом:

json
{ "message": "You are being rate limited.", "retry_after": 0.53, "global": false }

retry_after указан в секундах и может быть дробным. Подождите столько, затем повторите тот же запрос один раз. Заголовки X-RateLimit-Remaining и X-RateLimit-Reset-After позволяют аккуратному клиенту замедлиться ещё до того, как он упрётся в предел. Минимальная повторная отправка на Python:

python
import time, requestsdef send(url, payload, attempts=5):    for _ in range(attempts):        r = requests.post(url, json=payload, timeout=10)        if r.status_code != 429:            r.raise_for_status()            return r        time.sleep(float(r.json().get("retry_after", 1)))    raise RuntimeError("still rate limited")

Проблемы у вебхуков создаёт не редкий 429, а реакция на него. Скрипт, который мгновенно повторяет запрос в цикле, превращает полсекундное ожидание в лавину, а повторяющиеся некорректные запросы (ответы 401, 403 и 429) могут привести к временной блокировке IP-адреса отправителя во всём API Discord. Объединяйте запросы, где можно: десять строк лога, склеенных через переводы строк, - это один запрос; и делайте паузу при ошибках 5xx, которые Discord возвращает под нагрузкой.

Утёк URL вебхука: что делать#

URL вебхуков утекают самыми обыденными способами: попали в закоммиченный конфиг, вставлены в чат поддержки, видны на скриншоте, напечатаны в логе CI, встроены в клиентский JavaScript сайта. Последнее встречается чаще всего и проще всего предотвращается: любой URL, попавший в браузер, публичен, а существуют сканеры, которые ищут именно такой шаблон и используют находку, чтобы публиковать спам или фишинговые ссылки под баннером вашего сервера.

Когда URL утёк, порядок действий короткий, и выполнять его стоит именно в таком порядке:

  1. Удалите вебхук. Channel settings, Integrations, Webhooks, выберите вебхук, Delete Webhook. Или curl -X DELETE "$WEBHOOK_URL" откуда угодно. Старый URL перестанет работать немедленно. Варианта «сбросить токен» нет; удаление и повторное создание и есть ротация.
  2. Создайте новый в том же канале и поместите новый URL в переменную окружения или хранилище секретов, которое читает ваш отправитель. Перезапустите отправителя, чтобы он его подхватил.
  3. Очистите канал. Удалите всё, что опубликовал злоумышленник. Уже отправленные сообщения остаются после исчезновения вебхука, а раз вебхука больше нет, удалять их приходится вручную, как модератору.
  4. Уберите источник. Перепишите файл, отзовите paste, сделайте ротацию лога CI. Если URL был в Git-репозитории, удаления его новым коммитом недостаточно - он остаётся в истории, а утёкший URL и так уже не работает, так что сосредоточьтесь на том, чтобы новый никогда не попал туда же.
  5. Проверьте остальное. Server Settings, Integrations, Webhooks перечисляет каждый вебхук на сервере. Удалите те, происхождение которых никто не может объяснить.

Относитесь к URL как к паролю от базы данных: переменная окружения на отправляющем сервере, скрытый на скриншотах и в логах, по одному на отправителя, чтобы одна утечка означала одно удаление.

Вебхук или бот: как выбрать#

Выбор определяется направлением. Вебхук пишет в канал. Бот подключается к gateway Discord, получает события и может на них реагировать.

Вам нужноИспользуйте
Отправлять уведомления из скрипта, CI или сайтаВебхук
Позже изменять или удалять эти уведомленияВебхук
Писать под несколькими именами и аватарамиВебхук
Отвечать на слэш-команды или кнопкиБот
Читать чат, реакции или вступления участниковБот
Назначать роли, модерировать, отправлять личные сообщенияБот
Писать на многих серверах, которыми вы не владеетеБот, приглашённый на каждый

Вебхуку не нужно ничего запущенного между сообщениями, поэтому держать его ничего не стоит. Бот - это долгоживущий процесс с токеном, intents и соединением с gateway, которое должно оставаться на связи; если оно пропало, бот отображается как офлайн, а его команды не работают. Это отдельная задача хостинга, о ней рассказывают статьи hosting a Discord bot 24/7 для discord.js и hosting a discord.py bot для Python.

Стоит знать о двух гибридах. Бот может сам создавать вебхуки и публиковать через них сообщения под другими именами - так чат-мосты показывают внутриигровых игроков с их собственными именами и аватарами. А вебхук, созданный приложением (а не человеком в настройках канала), может отправлять интерактивные компоненты, такие как кнопки, потому что взаимодействия возвращаются этому приложению. Вебхук, созданный в настройках канала, ничего принимать не может, так что кнопкам на нём некуда было бы сообщать о нажатии.

Есть и отдельная функция, которую Discord называет webhook events: приложение может зарегистрировать HTTPS-эндпоинт в портале разработчика и получать определённые события, например авторизацию приложения, как входящие запросы. Это обратное направление - Discord обращается к вам - и предназначено оно для разработчиков приложений, а не как способ читать канал.

Последний вопрос - где работает отправитель. Вебхуку нужно лишь то, что умеет сделать запрос, когда происходит событие: задача CI, задание cron, уже существующий бэкенд. Если его ещё нет, хватит небольшого постоянно работающего сервера приложений. Тарифы RE:NODE для приложений на Node.js и Python начинаются от $4 в месяц, URL вебхука хранится как переменная окружения на вкладке Startup, а деплой идёт прямо из GitHub.

FAQ#

Нужно ли уметь программировать, чтобы использовать вебхук Discord?

Нет. Многие сервисы принимают URL вебхука и сами занимаются отправкой - вставьте его, и они начнут публиковать сообщения. Чтобы отправлять собственные сообщения, достаточно одной команды curl, а ещё есть веб-инструменты, которые собирают JSON за вас. Код нужен только тогда, когда ваша собственная программа решает, что и когда публиковать.

Почему мой вебхук возвращает 204 и не возвращает id сообщения?

Потому что 204 No Content - это стандартный ответ при успехе. Добавьте ?wait=true к URL, и Discord вернёт 200 с полным сообщением, включая id, который нужен, чтобы изменить или удалить его.

Может ли вебхук писать в несколько каналов?

Нет. Каждый вебхук принадлежит одному каналу, и через thread_id он может дотянуться только до тредов внутри этого канала. Для нескольких каналов создайте по вебхуку в каждом, что в любом случае лучше и с точки зрения утечек, и с точки зрения шума.

Может ли вебхук упоминать роль?

Да. Поместите <@&ROLE_ID> в content и укажите id в allowed_mentions.roles, оставив parse пустым, чтобы больше никто не получал уведомление. Id роли можно получить, щёлкнув роль правой кнопкой при включённом Developer Mode в расширенных настройках Discord; так же копируются id пользователей, каналов и сообщений.

Истекают ли URL вебхуков?

Нет. Вебхук работает, пока его не удалят, не удалят его канал или сервер не уберёт его. URL, который работал в прошлом году, а сегодня возвращает 404, был кем-то удалён - проверьте Server Settings, Integrations, Webhooks.

Почему вебхук отклоняет моё имя пользователя?

Имя должно содержать от 1 до 80 символов и не может включать discord или clyde. В теле ответа 400 указано, какое правило нарушено; сократите имя или уберите это слово.