REST API v1 · Developers
Webhooks: get Discord server events on your server
Instead of polling the API, let BotGG call you: when something happens on your Discord server — a balance change, a level-up, a purchase, a new ticket — BotGG sends a signed JSON POST to your HTTPS endpoint.
Developer docs
On this page
Set up an endpoint
- In the console, open your server’s API section and add a webhook endpoint: an
https://URL and the events you want. - Copy the signing secret (
whsec_…) — it is shown only once — and store it on your server, for example inBOTGG_WEBHOOK_SECRET. - Send a test event from the console: it is delivered as type
webhook.test. Check that your endpoint verifies the signature and answers2xx.
- Endpoints per server: 1 on Free, 10 on Pro.
- The URL must be public HTTPS without a user name or password in it; private and internal addresses are refused.
- Rotating the secret replaces it immediately — deploy the new secret to your server right after rotating.
What a delivery looks like
Each delivery is a POST with a JSON body { id, type, createdAt, guildId, data } — data depends on the event type (see events) — and these headers:
| Header | Description |
|---|---|
BotGG-Webhook-Id | Event ID (`evt_…`), the same on every retry — store it to ignore duplicates. |
BotGG-Webhook-Timestamp | Unix time in seconds when this attempt was signed. |
BotGG-Signature | HMAC-SHA256 signature of the timestamp and the raw body, made with your endpoint’s signing secret. |
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"}}- Answer with any
2xxstatus within 10 seconds. Anything else — an error status, a timeout or a redirect (redirects are never followed) — is a failed attempt. - Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours — up to 7 attempts in total — with the same event
id. - Deliveries can arrive more than once and out of order: deduplicate on
idand usecreatedAtfor ordering. - An endpoint that keeps failing for a long time (a day or more) is disabled automatically. Re-enable it in the console after fixing it; failed deliveries can be retried from there too.
Verify the signature
Always check that a request really comes from BotGG before trusting it. The signature is an HMAC-SHA256, in hex, of the timestamp header, a dot and the raw request body, made with your endpoint’s secret: BotGG-Signature: v1=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>.
- Read the raw body exactly as received (before any JSON parsing).
- Reject the request if
BotGG-Webhook-Timestampis more than 5 minutes away from your clock — this stops replays. - Compute the HMAC of
<timestamp>.<raw body>with your secret and compare it, in constant time, with eachv1=value inBotGG-Signature.
// 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 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 "", 204Events
Subscribe each endpoint to the events it needs. Payload shapes are stable within v1: fields may be added, never removed or retyped — ignore fields you don’t know. Discord IDs are strings; BotGG record IDs (transactions, purchases, tickets…) are integers.
| Event | Feature | Description |
|---|---|---|
bank.transaction | Bank | Bank transaction — Any balance change: daily rewards, transfers, shop charges, staff and API changes. |
levels.levelUp | Levels | Level up — A member reached a higher level (activity or a staff change). |
shop.purchase | Shop | Purchase — A member bought a Shop item. |
shop.refund | Shop | Refund — A Shop purchase was refunded. |
tickets.opened | Tickets | Ticket opened — A ticket channel or thread was created. |
tickets.closed | Tickets | Ticket closed — A ticket was closed (manually, automatically or because the opener left). |
members.joined | Members | Member joined — Someone joined the server. |
members.left | Members | Member left — Someone left the server (or was kicked / banned). |
giveaways.ended | Giveaways | Giveaway ended — A giveaway ended and its winners were drawn. |
moderation.caseCreated | Moderation | Moderation case — A new moderation case (warn, timeout, kick, ban, …) was recorded. |
applications.submitted | Applications | Application submitted — A member submitted an application form (answers included). |
appeals.submitted | Ban appeals | Ban appeal submitted — A banned user filed a ban appeal (answers included). |
bank.transaction Bank transaction
| Field | Type | Required | Description |
|---|---|---|---|
transactionId | integer | Yes | at least 0 |
userId | string | Yes | Discord snowflake id Discord ID (string) |
type | string | Yes | Ledger type: daily, transfer_in, transfer_out, fee, admin_add, admin_remove, admin_set, flow, api, … |
amount | integer | Yes | Signed change (negative = debit) |
balanceAfter | integer | Yes | |
reason | string | null | Yes | |
counterpartyId | string | null | Yes | Discord snowflake id Discord ID (string) |
actorId | string | null | Yes | Discord snowflake id Discord ID (string) |
createdAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Level up
| Field | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Discord snowflake id Discord ID (string) |
level | integer | Yes | at least 0 |
previousLevel | integer | Yes | at least 0 |
source | string | Yes | One of 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 Purchase
| Field | Type | Required | Description |
|---|---|---|---|
purchaseId | integer | Yes | at least 0 |
userId | string | Yes | Discord snowflake id Discord ID (string) |
item | object | Yes | |
item.id | integer | null | Yes | null when the item was deleted since at least 0 |
item.name | string | Yes | |
item.emoji | string | null | Yes | |
item.type | string | Yes | role | color | item | … |
store | object | Yes | id/name null = the built-in Main store |
store.id | integer | null | Yes | at least 0 |
store.name | string | null | Yes | |
price | integer | Yes | at least 0 |
status | string | Yes | owned | used | refunded |
roleId | string | null | Yes | Discord snowflake id Discord ID (string) |
expiresAt | string | null | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (UTC) |
createdAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Refund
| Field | Type | Required | Description |
|---|---|---|---|
purchaseId | integer | Yes | at least 0 |
userId | string | Yes | Discord snowflake id Discord ID (string) |
item | object | Yes | |
item.id | integer | null | Yes | null when the item was deleted since at least 0 |
item.name | string | Yes | |
item.emoji | string | null | Yes | |
item.type | string | Yes | role | color | item | … |
price | integer | Yes | at least 0 |
refundedBy | string | null | Yes | Discord snowflake id Discord ID (string) |
refundedAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Ticket opened
| Field | Type | Required | Description |
|---|---|---|---|
ticketId | integer | Yes | at least 0 |
number | integer | Yes | |
userId | string | Yes | Discord snowflake id Discord ID (string) |
panelId | integer | null | Yes | at least 0 |
channelId | string | null | Yes | Discord snowflake id Discord ID (string) |
subject | string | null | Yes | |
openedAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Ticket closed
| Field | Type | Required | Description |
|---|---|---|---|
ticketId | integer | Yes | at least 0 |
number | integer | Yes | |
userId | string | Yes | Discord snowflake id Discord ID (string) |
panelId | integer | null | Yes | at least 0 |
channelId | string | null | Yes | Discord snowflake id Discord ID (string) |
closedBy | string | null | Yes | Discord snowflake id Discord ID (string) |
closeReason | string | null | Yes | |
closeType | string | null | Yes | manual | auto | leave | … |
openedAt | string | null | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (UTC) |
closedAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Member joined
| Field | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Discord snowflake id Discord ID (string) |
bot | boolean | Yes | |
joinedAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (UTC) |
accountCreatedAt | string | null | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Member left
| Field | Type | Required | Description |
|---|---|---|---|
userId | string | Yes | Discord snowflake id Discord ID (string) |
bot | boolean | Yes | |
leftAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Giveaway ended
| Field | Type | Required | Description |
|---|---|---|---|
giveawayId | integer | Yes | at least 0 |
prize | string | Yes | |
channelId | string | null | Yes | Discord snowflake id Discord ID (string) |
messageId | string | null | Yes | Discord snowflake id Discord ID (string) |
hostId | string | null | Yes | Discord snowflake id Discord ID (string) |
winnerIds | array of string | Yes | |
entrants | integer | Yes | at least 0 |
endedAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Moderation case
| Field | Type | Required | Description |
|---|---|---|---|
caseId | integer | Yes | at least 0 |
number | integer | Yes | |
action | string | Yes | warn | timeout | kick | ban | tempban | unban | … |
targetId | string | Yes | Discord snowflake id Discord ID (string) |
moderatorId | string | null | Yes | Discord snowflake id Discord ID (string) |
reason | string | null | Yes | |
durationSeconds | integer | null | Yes | at least 0 |
source | string | null | Yes | |
expiresAt | string | null | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (UTC) |
createdAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Application submitted
| Field | Type | Required | Description |
|---|---|---|---|
submissionId | integer | Yes | at least 0 |
number | integer | Yes | |
userId | string | Yes | Discord snowflake id Discord ID (string) |
formId | integer | null | Yes | at least 0 |
formTitle | string | null | Yes | |
status | string | Yes | |
answers | array of object | Yes | |
answers[].questionId | string | Yes | |
answers[].label | string | Yes | |
answers[].type | string | Yes | Question type, e.g. short, paragraph, select, number, yesno |
answers[].value | string | null | Yes | |
answers[].text | string | null | Yes | Human-readable answer (as shown in Discord), or null |
submittedAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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 Ban appeal submitted
| Field | Type | Required | Description |
|---|---|---|---|
appealId | integer | Yes | at least 0 |
number | integer | Yes | |
userId | string | Yes | Discord snowflake id Discord ID (string) |
banCaseNumber | integer | null | Yes | |
banReason | string | null | Yes | |
answers | array of object | Yes | |
answers[].questionId | string | Yes | |
answers[].label | string | Yes | |
answers[].type | string | Yes | Question type, e.g. short, paragraph, select, number, yesno |
answers[].value | string | null | Yes | |
answers[].text | string | null | Yes | Human-readable answer (as shown in Discord), or null |
createdAt | string | Yes | ISO-8601 timestamp (UTC) ISO 8601 date-time (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"
}
}