REST API v1 · Разработчикам

Вебхуки: события Discord-сервера на вашем сервере

Вместо того чтобы опрашивать API, пусть BotGG сам вызывает вас: когда на Discord-сервере что-то происходит — меняется баланс, участник получает уровень, совершается покупка, открывается тикет, — BotGG отправляет подписанный JSON-запрос POST на ваш HTTPS-эндпоинт.

Документация для разработчиков
На этой странице

Настройка эндпоинта

  1. В консоли откройте раздел API своего сервера и добавьте эндпоинт вебхука: URL с https:// и нужные события.
  2. Скопируйте секрет подписи (whsec_…) — он показывается только один раз — и сохраните его на своём сервере, например в BOTGG_WEBHOOK_SECRET.
  3. Отправьте тестовое событие из консоли: оно приходит с типом webhook.test. Убедитесь, что эндпоинт проверяет подпись и отвечает 2xx.
  • Эндпоинтов на сервер: 1 на Free, 10 на Pro.
  • URL должен быть публичным HTTPS-адресом без логина и пароля; приватные и внутренние адреса не принимаются.
  • Смена секрета действует сразу — разверните новый секрет на своём сервере сразу после смены.

Как выглядит доставка

Каждая доставка — это POST с JSON-телом { id, type, createdAt, guildId, data } (data зависит от типа события, см. события) и такими заголовками:

Заголовки каждой доставки
ЗаголовокОписание
BotGG-Webhook-IdID события (`evt_…`), одинаковый при всех повторах — сохраняйте его, чтобы отбрасывать дубликаты.
BotGG-Webhook-TimestampUnix-время в секундах, когда подписана эта попытка.
BotGG-SignatureПодпись HMAC-SHA256 от времени и «сырого» тела, сделанная секретом подписи вашего эндпоинта.
Запрос
POST /botgg-webhook HTTP/1.1
Content-Type: application/json
User-Agent: BotGG-Webhooks/1.0 (+https://botgg.app/developers/)
BotGG-Webhook-Id: evt_3Kq9Zx2mV7pL4nR8tW1y
BotGG-Webhook-Timestamp: 1791374460
BotGG-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"id":"evt_3Kq9Zx2mV7pL4nR8tW1y","type":"levels.levelUp","createdAt":"2026-10-07T12:00:00.000Z","guildId":"123456789012345678","data":{"userId":"876543210987654321","level":5,"previousLevel":4,"source":"activity"}}
  • Ответьте любым статусом 2xx в течение 10 секунд. Всё остальное — статус ошибки, тайм-аут или редирект (по редиректам BotGG не переходит) — считается неудачной попыткой.
  • Неудачные доставки повторяются через 1 минуту, 5 минут, 30 минут, 2 часа, 6 часов и 24 часа — всего до 7 попыток — с тем же id события.
  • Доставка может прийти несколько раз и не по порядку: отбрасывайте дубликаты по id, а порядок определяйте по createdAt.
  • Эндпоинт, который долго не отвечает (сутки и дольше), автоматически отключается. Исправьте проблему и включите его снова в консоли — там же можно повторить неудачные доставки.

Проверка подписи

Всегда проверяйте, что запрос действительно пришёл от BotGG. Подпись — это HMAC-SHA256 в hex от заголовка времени, точки и «сырого» тела запроса, сделанная секретом вашего эндпоинта: BotGG-Signature: v1=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>.

  1. Прочитайте тело ровно в том виде, в каком оно пришло (до разбора JSON).
  2. Отклоните запрос, если BotGG-Webhook-Timestamp отличается от ваших часов больше чем на 5 минут, — это защищает от повторной отправки.
  3. Вычислите HMAC от <timestamp>.<сырое тело> своим секретом и сравните его за постоянное время с каждым значением v1= в BotGG-Signature.
Node.js (Express)
// Node.js 18+ with Express. Verify against the RAW body — not a re-serialized JSON object.
import crypto from 'node:crypto';
import express from 'express';

const SECRET = process.env.BOTGG_WEBHOOK_SECRET; // whsec_… (shown once in the console)
const TOLERANCE_SECONDS = 300;

function verify(rawBody, timestamp, signatureHeader) {
  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;
  const expected = crypto.createHmac('sha256', SECRET).update(`${ts}.${rawBody}`).digest('hex');
  return String(signatureHeader ?? '')
    .split(/[\s,]+/)
    .some((part) => {
      const m = /^v1=([0-9a-f]{64})$/.exec(part);
      return Boolean(m) && crypto.timingSafeEqual(Buffer.from(m[1]), Buffer.from(expected));
    });
}

const app = express();
app.post('/botgg-webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const raw = req.body.toString('utf8');
  if (!verify(raw, req.get('BotGG-Webhook-Timestamp'), req.get('BotGG-Signature'))) return res.status(400).end();
  const event = JSON.parse(raw);
  res.status(204).end(); // acknowledge within 10 s, then do the work
  // event.id is the same on every retry: skip events you have already handled
  console.log(event.type, event.data);
});
app.listen(3000);
Python (Flask)
# Python 3 with Flask. Verify against the RAW body bytes.
import hashlib, hmac, json, os, time
from flask import Flask, abort, request

SECRET = os.environ["BOTGG_WEBHOOK_SECRET"].encode()  # whsec_… (shown once in the console)
TOLERANCE_SECONDS = 300

def verify(raw: bytes, timestamp, header) -> bool:
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    for part in (header or "").replace(",", " ").split():
        if part.startswith("v1=") and hmac.compare_digest(part[3:], expected):
            return True
    return False

app = Flask(__name__)

@app.post("/botgg-webhook")
def botgg_webhook():
    raw = request.get_data()  # the exact bytes BotGG signed
    if not verify(raw, request.headers.get("BotGG-Webhook-Timestamp"), request.headers.get("BotGG-Signature")):
        abort(400)
    event = json.loads(raw)
    # event["id"] is the same on every retry: skip events you have already handled
    print(event["type"], event["data"])
    return "", 204

События

Подписывайте каждый эндпоинт только на нужные события. Форма данных в v1 стабильна: поля могут добавляться, но не удаляются и не меняют тип — просто игнорируйте незнакомые поля. ID Discord — строки, ID записей BotGG (операций, покупок, тикетов…) — целые числа.

События вебхуков
СобытиеМодульОписание
bank.transactionБанкОперация Банка — Любое изменение баланса: ежедневные награды, переводы, покупки, изменения персонала и через API.
levels.levelUpУровниНовый уровень — Участник достиг более высокого уровня (за активность или по решению персонала).
shop.purchaseМагазинПокупка — Участник купил товар в Магазине.
shop.refundМагазинВозврат — Покупка в Магазине возвращена.
tickets.openedТикетыТикет открыт — Создан канал или ветка тикета.
tickets.closedТикетыТикет закрыт — Тикет закрыт (вручную, автоматически или после ухода автора).
members.joinedУчастникиУчастник пришёл — Кто-то присоединился к серверу.
members.leftУчастникиУчастник ушёл — Кто-то покинул сервер (или был исключён / забанен).
giveaways.endedРозыгрышиРозыгрыш завершён — Розыгрыш завершён, победители выбраны.
moderation.caseCreatedМодерацияСлучай модерации — Записан новый случай модерации (предупреждение, тайм-аут, кик, бан, …).
applications.submittedЗаявкиЗаявка отправлена — Участник отправил форму заявки (с ответами).
appeals.submittedАпелляцииАпелляция отправлена — Забаненный пользователь подал апелляцию (с ответами).
bank.transaction Операция Банка
Поля `data` события bank.transaction
ПолеТипОбязательноеОписание
transactionIdintegerДане меньше 0
userIdstringДаDiscord snowflake id
Discord ID (строка)
typestringДаLedger type: daily, transfer_in, transfer_out, fee, admin_add, admin_remove, admin_set, flow, api, …
amountintegerДаSigned change (negative = debit)
balanceAfterintegerДа
reasonstring | nullДа
counterpartyIdstring | nullДаDiscord snowflake id
Discord ID (строка)
actorIdstring | nullДаDiscord snowflake id
Discord ID (строка)
createdAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "bank.transaction",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "transactionId": 98765,
    "userId": "876543210987654321",
    "type": "example",
    "amount": 100,
    "balanceAfter": 1350,
    "reason": "Tournament prize",
    "counterpartyId": "234567890123456789",
    "actorId": "234567890123456789",
    "createdAt": "2026-10-07T12:00:00.000Z"
  }
}
levels.levelUp Новый уровень
Поля `data` события levels.levelUp
ПолеТипОбязательноеОписание
userIdstringДаDiscord snowflake id
Discord ID (строка)
levelintegerДане меньше 0
previousLevelintegerДане меньше 0
sourcestringДаОдно из activity, admin
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "levels.levelUp",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "userId": "876543210987654321",
    "level": 5,
    "previousLevel": 4,
    "source": "activity"
  }
}
shop.purchase Покупка
Поля `data` события shop.purchase
ПолеТипОбязательноеОписание
purchaseIdintegerДане меньше 0
userIdstringДаDiscord snowflake id
Discord ID (строка)
itemobjectДа
item.idinteger | nullДаnull when the item was deleted since
не меньше 0
item.namestringДа
item.emojistring | nullДа
item.typestringДаrole | color | item | …
storeobjectДаid/name null = the built-in Main store
store.idinteger | nullДане меньше 0
store.namestring | nullДа
priceintegerДане меньше 0
statusstringДаowned | used | refunded
roleIdstring | nullДаDiscord snowflake id
Discord ID (строка)
expiresAtstring | nullДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
createdAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "shop.purchase",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "purchaseId": 98765,
    "userId": "876543210987654321",
    "item": {
      "id": 98765,
      "name": "Example",
      "emoji": "🪙",
      "type": "example"
    },
    "store": {
      "id": 98765,
      "name": "Example"
    },
    "price": 250,
    "status": "example",
    "roleId": "234567890123456789",
    "expiresAt": "2026-10-07T12:00:00.000Z",
    "createdAt": "2026-10-07T12:00:00.000Z"
  }
}
shop.refund Возврат
Поля `data` события shop.refund
ПолеТипОбязательноеОписание
purchaseIdintegerДане меньше 0
userIdstringДаDiscord snowflake id
Discord ID (строка)
itemobjectДа
item.idinteger | nullДаnull when the item was deleted since
не меньше 0
item.namestringДа
item.emojistring | nullДа
item.typestringДаrole | color | item | …
priceintegerДане меньше 0
refundedBystring | nullДаDiscord snowflake id
Discord ID (строка)
refundedAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "shop.refund",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "purchaseId": 98765,
    "userId": "876543210987654321",
    "item": {
      "id": 98765,
      "name": "Example",
      "emoji": "🪙",
      "type": "example"
    },
    "price": 250,
    "refundedBy": "234567890123456789",
    "refundedAt": "2026-10-07T12:00:00.000Z"
  }
}
tickets.opened Тикет открыт
Поля `data` события tickets.opened
ПолеТипОбязательноеОписание
ticketIdintegerДане меньше 0
numberintegerДа
userIdstringДаDiscord snowflake id
Discord ID (строка)
panelIdinteger | nullДане меньше 0
channelIdstring | nullДаDiscord snowflake id
Discord ID (строка)
subjectstring | nullДа
openedAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "tickets.opened",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "ticketId": 98765,
    "number": 7,
    "userId": "876543210987654321",
    "panelId": 98765,
    "channelId": "234567890123456789",
    "subject": "Help with my order",
    "openedAt": "2026-10-07T12:00:00.000Z"
  }
}
tickets.closed Тикет закрыт
Поля `data` события tickets.closed
ПолеТипОбязательноеОписание
ticketIdintegerДане меньше 0
numberintegerДа
userIdstringДаDiscord snowflake id
Discord ID (строка)
panelIdinteger | nullДане меньше 0
channelIdstring | nullДаDiscord snowflake id
Discord ID (строка)
closedBystring | nullДаDiscord snowflake id
Discord ID (строка)
closeReasonstring | nullДа
closeTypestring | nullДаmanual | auto | leave | …
openedAtstring | nullДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
closedAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "tickets.closed",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "ticketId": 98765,
    "number": 7,
    "userId": "876543210987654321",
    "panelId": 98765,
    "channelId": "234567890123456789",
    "closedBy": "234567890123456789",
    "closeReason": "example",
    "closeType": "example",
    "openedAt": "2026-10-07T12:00:00.000Z",
    "closedAt": "2026-10-07T12:00:00.000Z"
  }
}
members.joined Участник пришёл
Поля `data` события members.joined
ПолеТипОбязательноеОписание
userIdstringДаDiscord snowflake id
Discord ID (строка)
botbooleanДа
joinedAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
accountCreatedAtstring | nullДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "members.joined",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "userId": "876543210987654321",
    "bot": false,
    "joinedAt": "2026-10-07T12:00:00.000Z",
    "accountCreatedAt": "2026-10-07T12:00:00.000Z"
  }
}
members.left Участник ушёл
Поля `data` события members.left
ПолеТипОбязательноеОписание
userIdstringДаDiscord snowflake id
Discord ID (строка)
botbooleanДа
leftAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "members.left",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "userId": "876543210987654321",
    "bot": false,
    "leftAt": "2026-10-07T12:00:00.000Z"
  }
}
giveaways.ended Розыгрыш завершён
Поля `data` события giveaways.ended
ПолеТипОбязательноеОписание
giveawayIdintegerДане меньше 0
prizestringДа
channelIdstring | nullДаDiscord snowflake id
Discord ID (строка)
messageIdstring | nullДаDiscord snowflake id
Discord ID (строка)
hostIdstring | nullДаDiscord snowflake id
Discord ID (строка)
winnerIdsarray of stringДа
entrantsintegerДане меньше 0
endedAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "giveaways.ended",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "giveawayId": 98765,
    "prize": "Nitro",
    "channelId": "234567890123456789",
    "messageId": "234567890123456789",
    "hostId": "234567890123456789",
    "winnerIds": [
      "234567890123456789"
    ],
    "entrants": 42,
    "endedAt": "2026-10-07T12:00:00.000Z"
  }
}
moderation.caseCreated Случай модерации
Поля `data` события moderation.caseCreated
ПолеТипОбязательноеОписание
caseIdintegerДане меньше 0
numberintegerДа
actionstringДаwarn | timeout | kick | ban | tempban | unban | …
targetIdstringДаDiscord snowflake id
Discord ID (строка)
moderatorIdstring | nullДаDiscord snowflake id
Discord ID (строка)
reasonstring | nullДа
durationSecondsinteger | nullДане меньше 0
sourcestring | nullДа
expiresAtstring | nullДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
createdAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "moderation.caseCreated",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "caseId": 98765,
    "number": 7,
    "action": "example",
    "targetId": "234567890123456789",
    "moderatorId": "234567890123456789",
    "reason": "Tournament prize",
    "durationSeconds": 3600,
    "source": "example",
    "expiresAt": "2026-10-07T12:00:00.000Z",
    "createdAt": "2026-10-07T12:00:00.000Z"
  }
}
applications.submitted Заявка отправлена
Поля `data` события applications.submitted
ПолеТипОбязательноеОписание
submissionIdintegerДане меньше 0
numberintegerДа
userIdstringДаDiscord snowflake id
Discord ID (строка)
formIdinteger | nullДане меньше 0
formTitlestring | nullДа
statusstringДа
answersarray of objectДа
answers[].questionIdstringДа
answers[].labelstringДа
answers[].typestringДаQuestion type, e.g. short, paragraph, select, number, yesno
answers[].valuestring | nullДа
answers[].textstring | nullДаHuman-readable answer (as shown in Discord), or null
submittedAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "applications.submitted",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "submissionId": 98765,
    "number": 7,
    "userId": "876543210987654321",
    "formId": 98765,
    "formTitle": "example",
    "status": "example",
    "answers": [
      {
        "questionId": "234567890123456789",
        "label": "Example",
        "type": "example",
        "value": "example",
        "text": "Example answer"
      }
    ],
    "submittedAt": "2026-10-07T12:00:00.000Z"
  }
}
appeals.submitted Апелляция отправлена
Поля `data` события appeals.submitted
ПолеТипОбязательноеОписание
appealIdintegerДане меньше 0
numberintegerДа
userIdstringДаDiscord snowflake id
Discord ID (строка)
banCaseNumberinteger | nullДа
banReasonstring | nullДа
answersarray of objectДа
answers[].questionIdstringДа
answers[].labelstringДа
answers[].typestringДаQuestion type, e.g. short, paragraph, select, number, yesno
answers[].valuestring | nullДа
answers[].textstring | nullДаHuman-readable answer (as shown in Discord), or null
createdAtstringДаISO-8601 timestamp (UTC)
Дата и время ISO 8601 (UTC)
Пример тела доставки
{
  "id": "evt_3Kq9Zx2mV7pL4nR8tW1y",
  "type": "appeals.submitted",
  "createdAt": "2026-10-07T12:00:00.000Z",
  "guildId": "123456789012345678",
  "data": {
    "appealId": 98765,
    "number": 7,
    "userId": "876543210987654321",
    "banCaseNumber": 10,
    "banReason": "example",
    "answers": [
      {
        "questionId": "234567890123456789",
        "label": "Example",
        "type": "example",
        "value": "example",
        "text": "Example answer"
      }
    ],
    "createdAt": "2026-10-07T12:00:00.000Z"
  }
}

Частые вопросы

Нужен ли API-ключ, чтобы получать вебхуки?

Нет. Вебхуки подтверждаются подписью, сделанной секретом эндпоинта. API-ключ нужен, только если ваш сервер ещё и обращается к REST API.

Почему не сходится подпись?

Чаще всего потому, что тело разобрали и снова сериализовали перед хешированием. Считайте HMAC по «сырым» байтам, которые пришли, секретом whsec_… именно этого эндпоинта, и проверьте часы сервера.

Что будет, если мой сервер недоступен?

BotGG повторяет каждую доставку до 7 раз в течение примерно 33 часов. Если эндпоинт долго не отвечает, он отключается; после исправления неудачные доставки можно повторить из консоли.

Готовы прокачать свой сервер?

Добавьте BotGG бесплатно и включайте нужные функции в веб-консоли.