A professional Top.gg vote system for Discord.js bots. Receive signed vote events, reward members, send polished Discord cards, expose leaderboards, query the Top.gg API, and automatically post bot statistics.
- Features
- Requirements
- Installation
- Quick start with Top.gg v1
- Top.gg dashboard setup
- Discord interface
- Vote rewards and reminders
- Vote statistics
- Top.gg v1 API methods
- Automatic bot-stat posting
- Legacy v0 webhooks
- Configuration reference
- Events
- Public methods
- TypeScript
- Security
- Troubleshooting
- Contributing
- License
- Top.gg v1
vote.createandwebhook.testevents - HMAC SHA-256 verification over the untouched request body
- Constant-time signature comparison
- Configurable replay-protection window
- Nested v1 payload normalization
- Legacy v0 Authorization-header webhooks through
@top-gg/sdk - Automatic selection between v1 and v0 when
webhookVersionis omitted - Duplicate v1 delivery protection using the Top.gg vote ID
- Weekend and multiplier vote weights
- Exact
created_atandexpires_athandling - Immediate webhook acknowledgement followed by background Discord processing
- Branded vote cards using Discord embeds
- Special gold weekend-boost presentation
- Voter avatar, vote power, streak, support points, and next-vote timestamp
- Native Vote on Top.gg and View bot profile buttons
- Optional Support server button
- Optional banner image, custom colors, title, and footer
- Safe mention defaults that prevent unexpected notification pings
- Channel or Discord webhook delivery
- Optional voter mention
- Optional
/votecommand center - Ephemeral command responses by default
- Optional dynamic Discord presence with live vote totals
- Optional voter role assignment
- Automatic role removal when the vote expires
- Vote-again direct-message reminders
- Exact v1 reminder timing from Top.gg
- Configurable legacy reminder delay
- Weighted per-user totals
- Vote streak tracking
- Local leaderboard
- Overall and per-user HTTP statistics
- Optional Bearer protection for statistics routes
- Fetch the authenticated Top.gg project
- Check a Discord or Top.gg user's active vote
- Read cursor-paginated vote history
- Automatically post Discord server and shard counts
- Health endpoint
- Custom Express application support
- Custom HTTP server support
- Configurable host, port, webhook route, and JSON body limit
- Graceful shutdown
- CommonJS and TypeScript declarations
- Node.js 18.18.2 or newer
- Discord.js 14
- A Discord bot with the
Guildsintent - The
GuildMembersintent when assigning or removing voter roles - A public HTTPS URL that Top.gg can reach
npm install vote-trackerSecrets should be stored in environment variables:
DISCORD_TOKEN=your-discord-bot-token
TOPGG_WEBHOOK_SECRET=whs_your_webhook_secret
TOPGG_API_TOKEN=your_topgg_api_token
STATS_TOKEN=a_private_token_for_stats_routesconst { Client, GatewayIntentBits } = require("discord.js");
const VoteTracker = require("vote-tracker");
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
],
});
const tracker = new VoteTracker(client, {
guildId: "YOUR_GUILD_ID",
channelId: "YOUR_VOTE_LOG_CHANNEL_ID",
roleId: "OPTIONAL_VOTER_ROLE_ID",
webhookVersion: "v1",
webhookSecret: process.env.TOPGG_WEBHOOK_SECRET,
webhookPath: "/dblwebhook",
port: 3000,
apiToken: process.env.TOPGG_API_TOKEN,
reminder: true,
stats: {
enabled: true,
endpoint: "/stats",
authToken: process.env.STATS_TOKEN,
leaderboardSize: 10,
streakWindowHours: 14,
},
discordUi: {
accentColor: "#5865F2",
weekendColor: "#F0B232",
title: "A new supporter appeared!",
footer: "Powered by Top.gg - Thank you for supporting us",
supportUrl: "https://discord.gg/your-server",
commands: {
enabled: true,
ephemeral: true,
leaderboardSize: 10,
},
presence: {
enabled: true,
text: "{votes} votes from {voters} supporters - /vote",
status: "online",
},
},
});
tracker.on("ready", ({ port, webhookPath }) => {
console.log(`Vote webhook ready on port ${port}${webhookPath}`);
});
tracker.on("vote", ({ user, vote, stats }) => {
console.log(`${user.username}'s vote counted ${vote.weight}x`, stats);
});
tracker.on("webhookTest", ({ traceId }) => {
console.log("Verified Top.gg test event:", traceId);
});
tracker.on("commandsReady", ({ command }) => {
console.log(`Registered /${command.name}`);
});
tracker.on("statsPosted", () => {
console.log("Posted bot statistics to Top.gg");
});
tracker.on("error", console.error);
client.once("ready", () => {
tracker.init();
});
client.login(process.env.DISCORD_TOKEN);- Open the project dashboard on Top.gg.
- Open Webhooks.
- Add
https://your-domain.example/dblwebhook. - Subscribe to
vote.createandwebhook.test. - Copy the generated secret beginning with
whs_. - Store it as
TOPGG_WEBHOOK_SECRET. - Send a test event from the dashboard.
- Listen for
webhookTestto confirm the signed request arrived.
The URL path must match webhookPath. The server must be accessible over public HTTPS; localhost cannot be called by Top.gg.
Every accepted vote produces a Discord message containing:
- The voter and their avatar
- Standard or weekend-boost vote power
- Weighted lifetime support recorded by this process
- The current streak
- A Discord relative timestamp for the next eligible vote
- Direct links to vote and view the bot on Top.gg
- An optional support-server link
The interface is enabled by default. Disable it with:
discordUi: falsediscordUi: {
accentColor: "#5865F2",
weekendColor: "#F0B232",
title: "Thanks for voting!",
footer: "Your Community - Powered by Top.gg",
bannerUrl: "https://example.com/banner.png",
supportUrl: "https://discord.gg/example",
mentionVoter: false,
showAvatar: true,
showStats: true,
buttons: true
}mentionVoter: false still displays the voter inside the embed. Vote messages use allowedMentions so embed text does not accidentally notify members.
Channel mode sends the vote card through the bot:
postmode: "channel",
channelId: "VOTE_LOG_CHANNEL_ID"Webhook mode sends the same card and link buttons through a Discord webhook:
postmode: "webhook",
webhook: "https://discord.com/api/webhooks/..."The webhook name and avatar are updated to match the bot's vote tracker branding.
Slash commands are opt-in:
discordUi: {
commandName: "vote",
commands: true
}Or configure them:
discordUi: {
commands: {
enabled: true,
ephemeral: true,
leaderboardSize: 10
}
}The package registers one guild command:
/vote dashboarddisplays the caller's vote eligibility, points, streak, and voting link./vote leaderboarddisplays the highest local weighted vote totals./vote stats [user]displays a selected member's vote dashboard.
When apiToken is configured, dashboard and member-stat responses also check the current vote through the Top.gg v1 API. Commands require the applications.commands scope, which is normally included when a Discord bot is invited.
discordUi: {
presence: {
enabled: true,
text: "{votes} votes from {voters} supporters - /vote",
status: "online"
}
}Available placeholders:
| Placeholder | Value |
|---|---|
{votes} |
Weighted vote total |
{voters} |
Unique local voter count |
The presence updates at startup and after each processed vote. A Discord.js ActivityType may be passed with type.
Set roleId to assign a guild role when a vote is processed:
roleId: "VOTER_ROLE_ID"The bot must have Manage Roles, and its highest role must be above the voter role. The role is removed when the reminder timer expires, whether or not direct-message reminders are enabled.
reminder: trueFor v1 events, the timer uses the exact Top.gg expires_at value. For v0 events, it uses reminderDelayHours, which defaults to 12 hours.
At expiry:
- The package attempts to send the voter a direct-message reminder when
reminderis enabled. - The configured voter role is removed.
Users may block direct messages; failures are logged without crashing the tracker.
Statistics are kept in memory. They reset whenever the Node.js process restarts.
Top.gg's weight is respected:
- A standard vote adds
1tototalVotesand the user'scount. - A double weekend vote adds
2. voteEventsand the user'seventsalways count deliveries, not vote weight.
A vote continues a streak when it arrives inside stats.streakWindowHours, which defaults to 14 hours.
stats: {
enabled: true,
endpoint: "/stats",
authToken: process.env.STATS_TOKEN,
leaderboardSize: 10,
streakWindowHours: 14
}The following routes are registered:
| Route | Response |
|---|---|
GET /stats |
Overall totals and unique voter count |
GET /stats/leaderboard?limit=10 |
Top voters; limit is capped at 100 |
GET /stats/users/:id |
One user's local statistics |
When authToken is configured:
Authorization: Bearer YOUR_STATS_TOKENOverall response:
{
"totalVotes": 42,
"voteEvents": 37,
"uniqueVoters": 21
}User response:
{
"userId": "123456789012345678",
"count": 5,
"events": 4,
"lastVoteAt": 1783072800000,
"streak": 3
}GET /health is always available:
{
"status": "ok",
"webhookVersion": "v1",
"uptime": 120.5
}Set apiToken to enable the Bearer-authenticated API client:
apiToken: process.env.TOPGG_API_TOKENconst project = await tracker.getProject();
console.log(project.name, project.votes, project.votes_total);Discord user ID:
const vote = await tracker.getVote("DISCORD_USER_ID");Top.gg user ID:
const vote = await tracker.getVote("TOPGG_USER_ID", "topgg");getVote() returns null when the user has no active vote.
The first request requires a date no more than one year in the past:
const page = await tracker.getVotes({
startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
});Use the returned cursor for the next page:
const nextPage = await tracker.getVotes({
cursor: page.cursor,
});Failed API requests throw TopggApiError with:
message: Top.gg's human-readable errorstatus: HTTP statusdetails: parsed problem response
Enable server and shard count posting:
apiToken: process.env.TOPGG_API_TOKEN,
autoPost: trueOr configure the current topgg-autoposter package:
autoPost: {
interval: 30 * 60 * 1000,
postOnStart: true,
startPosting: true
}Top.gg requires an interval of at least 15 minutes. Successful posts emit statsPosted; failures emit error.
Existing v0 integrations remain supported:
const tracker = new VoteTracker(client, {
guildId: "YOUR_GUILD_ID",
channelId: "YOUR_CHANNEL_ID",
webhookVersion: "v0",
password: process.env.TOPGG_LEGACY_WEBHOOK_PASSWORD
});For legacy webhooks, password must match the Authorization value configured in the Top.gg dashboard.
When webhookVersion is omitted:
webhookSecretselects v1.- A
passwordbeginning withwhs_selects v1. - Any other
passwordselects v0.
| Option | Type | Default | Description |
|---|---|---|---|
guildId |
string |
required | Discord guild that receives rewards and commands |
channelId |
string |
none | Vote-log channel |
roleId |
string |
none | Temporary voter role |
postmode |
"channel" | "webhook" |
"channel" |
Vote-card delivery mode |
webhook |
string |
none | Discord webhook URL for webhook post mode |
color |
ColorResolvable |
#333333 |
Legacy color and Discord UI accent fallback |
port |
number |
3000 |
HTTP port; use 0 to select a free port |
host |
string |
system default | HTTP listen address |
reminder |
boolean |
true |
Send a vote-again DM |
reminderDelayHours |
number |
12 |
Legacy v0 reminder delay |
app |
Express Application |
new app | Existing Express app |
server |
HTTP Server |
new server | Existing HTTP server |
| Option | Type | Default | Description |
|---|---|---|---|
webhookVersion |
"v0" | "v1" |
auto | Top.gg webhook generation |
webhookSecret |
string |
none | v1 whs_... signing secret |
password |
string |
none | Legacy v0 Authorization secret; accepted as a v1 fallback |
webhookPath |
string |
/dblwebhook |
Incoming Top.gg route |
signatureToleranceSeconds |
number |
300 |
Maximum accepted v1 signature age; Infinity disables the age check |
bodyLimit |
string |
100kb |
Express request-body limit |
| Option | Type | Default | Description |
|---|---|---|---|
apiToken |
string |
none | Top.gg API token |
apiBaseUrl |
string |
https://top.gg/api/v1 |
API base URL, mainly for testing or proxies |
fetch |
function |
global fetch |
Custom fetch-compatible implementation |
autoPost |
boolean | object |
false |
Server/shard count auto-poster |
| Option | Type | Default | Description |
|---|---|---|---|
stats.enabled |
boolean |
true |
Collect local statistics |
stats.leaderboardSize |
number |
10 |
Default leaderboard size |
stats.streakWindowHours |
number |
14 |
Maximum gap that continues a streak |
stats.endpoint |
string |
none | Enables statistics HTTP routes |
stats.authToken |
string |
none | Protects statistics routes with Bearer authentication |
| Option | Type | Default | Description |
|---|---|---|---|
discordUi.enabled |
boolean |
true |
Enable professional Discord cards |
discordUi.accentColor |
ColorResolvable |
#5865F2 |
Standard card color |
discordUi.weekendColor |
ColorResolvable |
#F0B232 |
Multiplier card color |
discordUi.title |
string |
package title | Vote-card title |
discordUi.footer |
string |
package footer | Card footer |
discordUi.bannerUrl |
string |
none | Vote-card image |
discordUi.supportUrl |
string |
none | Optional support-server button |
discordUi.mentionVoter |
boolean |
false |
Add voter mention content without generating a ping |
discordUi.showAvatar |
boolean |
true |
Show voter thumbnail |
discordUi.showStats |
boolean |
true |
Show points and streak |
discordUi.buttons |
boolean |
true |
Show link buttons |
discordUi.commandName |
string |
vote |
Slash-command name |
discordUi.commands |
boolean | object |
false |
Enable and configure slash commands |
discordUi.presence |
boolean | object |
false |
Enable and configure live presence |
The HTTP server is listening:
tracker.on("ready", ({ port, webhookPath }) => {});A verified vote was normalized, its user was fetched, and statistics were recorded:
tracker.on("vote", ({ user, guild, vote, stats }) => {});Normalized vote fields:
{
id,
user,
bot,
type,
isWeekend,
weight,
createdAt,
expiresAt,
sourceVersion,
raw
}A signed Top.gg v1 test event was accepted:
tracker.on("webhookTest", ({ payload, traceId }) => {});The optional guild slash command was registered:
tracker.on("commandsReady", ({ command }) => {});The auto-poster successfully submitted server statistics:
tracker.on("statsPosted", (data) => {});Discord processing, command registration, Top.gg API, or auto-poster work failed:
tracker.on("error", (error) => {
console.error(error);
});Without an error listener, Vote Tracker logs operational errors to the console.
| Method | Result |
|---|---|
init() |
Registers routes/features, starts auto-posting, and listens; returns the tracker |
stop() |
Stops auto-posting and reminders, removes the interaction listener, and closes the HTTP server |
getStats() |
Returns overall in-memory statistics |
getUserStats(userId) |
Returns one user's statistics or null |
getLeaderboard(limit?) |
Returns ranked local user statistics |
getProject() |
Fetches the authenticated Top.gg project |
getVote(userId, source?) |
Returns active vote information or null |
getVotes({ startDate }) |
Fetches the first vote-history page |
getVotes({ cursor }) |
Fetches the next vote-history page |
startAutoPoster() |
Starts or returns the configured Top.gg auto-poster |
Graceful shutdown:
async function shutdown() {
await tracker.stop();
client.destroy();
}
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);The package includes declarations for options, events, normalized votes, statistics, and Top.gg API responses.
import { Client, GatewayIntentBits } from "discord.js";
import VoteTracker, {
TopggApiError,
VoteTrackerOptions,
} from "vote-tracker";
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
const options: VoteTrackerOptions = {
guildId: process.env.GUILD_ID!,
webhookVersion: "v1",
webhookSecret: process.env.TOPGG_WEBHOOK_SECRET!,
};
const tracker = new VoteTracker(client, options);
try {
await tracker.getProject();
} catch (error) {
if (error instanceof TopggApiError) {
console.error(error.status, error.details);
}
}- V1 signatures are verified before JSON parsing.
- The signed value is
{timestamp}.{rawBody}. - HMAC SHA-256 signatures use constant-time comparison.
- Requests outside the configured timestamp tolerance are rejected.
- Duplicate v1 vote IDs are ignored during their in-memory retention window.
- Statistics routes can require Bearer authentication.
- Discord output disables automatic mention parsing.
- Tokens and secrets should never be committed.
- Production deployments should use HTTPS and a reverse proxy with appropriate request limits.
Security vulnerabilities should not be posted publicly. Follow SECURITY.md for responsible reporting.
- Confirm that
webhookSecretis the v1 secret beginning withwhs_. - Do not use the API token as the webhook secret.
- Ensure a proxy is not rewriting the request body.
- Check that the server clock is synchronized.
- Confirm the bot can view and send messages in
channelId. - Confirm
postmodematches the configured channel or Discord webhook. - Add an
errorlistener. - Check that the Discord client is ready before calling
init().
- Enable the
GuildMembersintent. - Grant Manage Roles.
- Move the bot role above the voter role.
- Confirm
roleIdbelongs toguildId.
- Enable
discordUi.commands. - Ensure the bot was invited with
applications.commands. - Confirm the configured
guildIdis correct. - Listen for
commandsReadyanderror.
getProject(), getVote(), and getVotes() require apiToken.
Contributions are welcome. Read CONTRIBUTING.md for:
- Development setup
- Bug and feature proposal workflow
- Code and test expectations
- Pull-request requirements
- Security reporting
Use the repository's GitHub issue forms for bugs and feature requests.
Vote Tracker is released under the MIT License.