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

  1. In the console, open your server’s API section and add a webhook endpoint: an https:// URL and the events you want.
  2. Copy the signing secret (whsec_…) — it is shown only once — and store it on your server, for example in BOTGG_WEBHOOK_SECRET.
  3. Send a test event from the console: it is delivered as type webhook.test. Check that your endpoint verifies the signature and answers 2xx.
  • 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:

Headers of every delivery
HeaderDescription
BotGG-Webhook-IdEvent ID (`evt_…`), the same on every retry — store it to ignore duplicates.
BotGG-Webhook-TimestampUnix time in seconds when this attempt was signed.
BotGG-SignatureHMAC-SHA256 signature of the timestamp and the raw body, made with your endpoint’s signing secret.
Request
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 2xx status 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 id and use createdAt for 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>")>.

  1. Read the raw body exactly as received (before any JSON parsing).
  2. Reject the request if BotGG-Webhook-Timestamp is more than 5 minutes away from your clock — this stops replays.
  3. Compute the HMAC of <timestamp>.<raw body> with your secret and compare it, in constant time, with each v1= value in 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

Events

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.

Webhook events
EventFeatureDescription
bank.transactionBankBank transaction — Any balance change: daily rewards, transfers, shop charges, staff and API changes.
levels.levelUpLevelsLevel up — A member reached a higher level (activity or a staff change).
shop.purchaseShopPurchase — A member bought a Shop item.
shop.refundShopRefund — A Shop purchase was refunded.
tickets.openedTicketsTicket opened — A ticket channel or thread was created.
tickets.closedTicketsTicket closed — A ticket was closed (manually, automatically or because the opener left).
members.joinedMembersMember joined — Someone joined the server.
members.leftMembersMember left — Someone left the server (or was kicked / banned).
giveaways.endedGiveawaysGiveaway ended — A giveaway ended and its winners were drawn.
moderation.caseCreatedModerationModeration case — A new moderation case (warn, timeout, kick, ban, …) was recorded.
applications.submittedApplicationsApplication submitted — A member submitted an application form (answers included).
appeals.submittedBan appealsBan appeal submitted — A banned user filed a ban appeal (answers included).
bank.transaction Bank transaction
`data` fields of bank.transaction
FieldTypeRequiredDescription
transactionIdintegerYesat least 0
userIdstringYesDiscord snowflake id
Discord ID (string)
typestringYesLedger type: daily, transfer_in, transfer_out, fee, admin_add, admin_remove, admin_set, flow, api, …
amountintegerYesSigned change (negative = debit)
balanceAfterintegerYes
reasonstring | nullYes
counterpartyIdstring | nullYesDiscord snowflake id
Discord ID (string)
actorIdstring | nullYesDiscord snowflake id
Discord ID (string)
createdAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of levels.levelUp
FieldTypeRequiredDescription
userIdstringYesDiscord snowflake id
Discord ID (string)
levelintegerYesat least 0
previousLevelintegerYesat least 0
sourcestringYesOne of activity, admin
Example delivery body
{
  "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
`data` fields of shop.purchase
FieldTypeRequiredDescription
purchaseIdintegerYesat least 0
userIdstringYesDiscord snowflake id
Discord ID (string)
itemobjectYes
item.idinteger | nullYesnull when the item was deleted since
at least 0
item.namestringYes
item.emojistring | nullYes
item.typestringYesrole | color | item | …
storeobjectYesid/name null = the built-in Main store
store.idinteger | nullYesat least 0
store.namestring | nullYes
priceintegerYesat least 0
statusstringYesowned | used | refunded
roleIdstring | nullYesDiscord snowflake id
Discord ID (string)
expiresAtstring | nullYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
createdAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of shop.refund
FieldTypeRequiredDescription
purchaseIdintegerYesat least 0
userIdstringYesDiscord snowflake id
Discord ID (string)
itemobjectYes
item.idinteger | nullYesnull when the item was deleted since
at least 0
item.namestringYes
item.emojistring | nullYes
item.typestringYesrole | color | item | …
priceintegerYesat least 0
refundedBystring | nullYesDiscord snowflake id
Discord ID (string)
refundedAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of tickets.opened
FieldTypeRequiredDescription
ticketIdintegerYesat least 0
numberintegerYes
userIdstringYesDiscord snowflake id
Discord ID (string)
panelIdinteger | nullYesat least 0
channelIdstring | nullYesDiscord snowflake id
Discord ID (string)
subjectstring | nullYes
openedAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of tickets.closed
FieldTypeRequiredDescription
ticketIdintegerYesat least 0
numberintegerYes
userIdstringYesDiscord snowflake id
Discord ID (string)
panelIdinteger | nullYesat least 0
channelIdstring | nullYesDiscord snowflake id
Discord ID (string)
closedBystring | nullYesDiscord snowflake id
Discord ID (string)
closeReasonstring | nullYes
closeTypestring | nullYesmanual | auto | leave | …
openedAtstring | nullYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
closedAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of members.joined
FieldTypeRequiredDescription
userIdstringYesDiscord snowflake id
Discord ID (string)
botbooleanYes
joinedAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
accountCreatedAtstring | nullYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of members.left
FieldTypeRequiredDescription
userIdstringYesDiscord snowflake id
Discord ID (string)
botbooleanYes
leftAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of giveaways.ended
FieldTypeRequiredDescription
giveawayIdintegerYesat least 0
prizestringYes
channelIdstring | nullYesDiscord snowflake id
Discord ID (string)
messageIdstring | nullYesDiscord snowflake id
Discord ID (string)
hostIdstring | nullYesDiscord snowflake id
Discord ID (string)
winnerIdsarray of stringYes
entrantsintegerYesat least 0
endedAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of moderation.caseCreated
FieldTypeRequiredDescription
caseIdintegerYesat least 0
numberintegerYes
actionstringYeswarn | timeout | kick | ban | tempban | unban | …
targetIdstringYesDiscord snowflake id
Discord ID (string)
moderatorIdstring | nullYesDiscord snowflake id
Discord ID (string)
reasonstring | nullYes
durationSecondsinteger | nullYesat least 0
sourcestring | nullYes
expiresAtstring | nullYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
createdAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of applications.submitted
FieldTypeRequiredDescription
submissionIdintegerYesat least 0
numberintegerYes
userIdstringYesDiscord snowflake id
Discord ID (string)
formIdinteger | nullYesat least 0
formTitlestring | nullYes
statusstringYes
answersarray of objectYes
answers[].questionIdstringYes
answers[].labelstringYes
answers[].typestringYesQuestion type, e.g. short, paragraph, select, number, yesno
answers[].valuestring | nullYes
answers[].textstring | nullYesHuman-readable answer (as shown in Discord), or null
submittedAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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
`data` fields of appeals.submitted
FieldTypeRequiredDescription
appealIdintegerYesat least 0
numberintegerYes
userIdstringYesDiscord snowflake id
Discord ID (string)
banCaseNumberinteger | nullYes
banReasonstring | nullYes
answersarray of objectYes
answers[].questionIdstringYes
answers[].labelstringYes
answers[].typestringYesQuestion type, e.g. short, paragraph, select, number, yesno
answers[].valuestring | nullYes
answers[].textstring | nullYesHuman-readable answer (as shown in Discord), or null
createdAtstringYesISO-8601 timestamp (UTC)
ISO 8601 date-time (UTC)
Example delivery body
{
  "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"
  }
}

Frequently asked questions

Do I need the API key to receive webhooks?

No. Webhooks are authenticated by the signature made with the endpoint’s signing secret. Use an API key only when your server also calls the REST API.

Why does my signature check fail?

Usually because the body was parsed and re-serialized before hashing. Compute the HMAC over the raw bytes you received, with the whsec_… secret of that endpoint, and check your server clock.

What happens when my server is down?

BotGG retries each delivery up to 7 times over about 33 hours. If an endpoint keeps failing for a long time it is disabled; failed deliveries can be retried from the console once it is fixed.

Ready to level up your server?

Add BotGG for free, then switch features on in the web console.