Introduction
What discord-botlists is and why it exists.
discord-botlists is a zero dependency SDK that connects your Discord bot to every botlist at once. It posts your server and shard counts in each list exact wire format, receives votes, comments, reviews and ratings in realtime through one webhook server, parses any list API response into a single normalized shape, and tracks the health of every list it supports.
- Post stats to 30+ verified live lists with one call, each mapped to its exact endpoint, auth header and field names.
- Realtime typed events for votes, comments, reviews and ratings. No polling, no delay.
- Universal parser: fetchBot returns the same UniversalBot shape on every list.
- Status tracking: hourly probes detect deprecated and shutdown lists, prune them from the registry automatically, and show latency on the status page.
- Fully typed with strict TypeScript. Zero runtime dependencies. Node 18+ and Bun.
Installation
Install the package and requirements.
pick your package manager
npm install @potenfyrstudios/discord-botlists
bun add @potenfyrstudios/discord-botlists
pnpm add @potenfyrstudios/discord-botlists| Requirement | Minimum | Notes |
|---|---|---|
Node.js | 18.0.0 | uses global fetch and node:http |
Bun | 1.1.0 | fully supported, recommended for tests |
TypeScript | 5.0 | optional, types ship in the package |
discord.js / Eris | optional | pass your client to auto collect stats |
Framework support
discord.js, Eris, Oceanic, anything else, or no framework at all.
The SDK has no framework lock-in. Pass any client object that exposes a guilds collection and it reads server and shard counts automatically. Or skip the client entirely and hand over the numbers yourself.
| Framework | How to use | Auto collection |
|---|---|---|
discord.js | new Botlists({ client }) | guilds.cache.size, shard data |
Eris | new Botlists({ client }) | guilds Map size |
Oceanic | new Botlists({ client }) | guilds Map size |
Other frameworks | new Botlists({ statsProvider }) | you provide it |
No framework | new Botlists({ statsProvider }) or explicit stats | you provide it |
statsProvider: full control, any framework or none
const lists = new Botlists({
statsProvider: async () => ({
serverCount: shardManager.totalGuilds,
shardCount: shardManager.shardCount,
shards: shardManager.perShardCounts,
}),
});
// or per call, no constructor changes
await lists.postStats({ serverCount: myGuildCount });Setup and tokens
Constructing Botlists and providing tokens.
the usual setup
import { Client } from 'discord.js';
import { Botlists } from '@potenfyrstudios/discord-botlists';
const client = new Client({ intents: ['Guilds'] });
const lists = new Botlists({
client, // optional: discord.js or Eris client
tokens: {
'top.gg': process.env.TOPGG_TOKEN,
'discordbotlist.com': process.env.DBL_TOKEN,
},
webhook: {
port: 8080, // default 8080
path: '/discord-botlists', // default
secret: process.env.WEBHOOK_SECRET,
},
startupStatusCheck: true, // print the status table on first postStats
});Token resolution order
- Environment variables: DBL_<LIST-ID-UPPERCASED>, for example DBL_TOP.GG.
- The tokens map in the constructor overrides env per list id.
- Lists without a token are skipped by postStats and reported as skipped, never as failures.
| Option | Type | Default | Description |
|---|---|---|---|
botId | string | client.user.id | Your bot application id |
client | BotClientLike | null | discord.js, Eris or Oceanic client for auto stats |
statsProvider | StatsProvider | null | Provide stats yourself: any framework or none |
tokens | Record<string,string> | env DBL_* | Per list tokens, overrides env |
authHeaders | Record<string,string> | per list default | Override the auth header name per list |
lists | BotlistRecord[] | [] | Add custom or self-hosted lists |
webhook | WebhookOptions | {} | Webhook server config |
fetchOptions | FetchOptions | timeout 10s, 2 retries | Applied to every request |
disableStatusCheck | boolean | false | Skip pre-request status lookups |
startupStatusCheck | boolean | false | Probe and print the status table on first post |
Posting stats
postStats, postStatsTo, postViaBotBlock and auto posting.
// every list you have a token for
const report = await lists.postStats({ serverCount: 120 });
report.posted; // 4
report.failed; // 0
report.skipped; // lists without tokens
// shard aware posting
await lists.postStats({
serverCount: client.guilds.cache.size,
shardCount: client.shard?.count,
shards: await client.shard?.fetchClientValues('guilds.cache.size') as number[],
});
// only some lists, or skip some
await lists.postStats({ serverCount: 120 }, { only: ['top.gg', 'botlist.me'] });
await lists.postStats({ serverCount: 120 }, { skip: ['bots.ondiscord.xyz'] });
// one specific list
await lists.postStatsTo('radarcord', { serverCount: 120 });
// one request, botblock fans out to every list for you
await lists.postViaBotBlock({ serverCount: 120 });Wire format mapping
Every list names the server count differently. The registry stores the exact field per list and builds the correct body automatically:
| List | Body field | Auth header |
|---|---|---|
top.gg | server_count | Authorization |
discord.bots.gg | guildCount | Authorization |
discordbotlist.com | guilds | Authorization |
botlist.me | server_count | authorization |
disforge.com | servers | Authorization |
discordlist.gg | count | Authorization |
discord.rovelstars.com | count | authorization |
auto posting on an interval
lists.startAutoPost(30 * 60 * 1000); // every 30 minutes, minimum 60s
lists.stopAutoPost();Realtime events
Votes, comments, reviews and ratings with zero delay.
startWebhooks launches a dependency free http server. Point each list dashboard at your address with the list id appended and payloads arrive as typed events instantly.
await lists.startWebhooks();
console.log(lists.webhook.address); // http://localhost:8080/discord-botlists
// give each dashboard:
// https://yourdomain.dev/discord-botlists/top.gg
// https://yourdomain.dev/discord-botlists/botlist.me
lists.on('vote', (vote) => {
vote.listId; // 'top.gg'
vote.voterId; // '160105994217586689'
vote.voterName; // 'someuser'
vote.weight; // 2 on weekend multiplier lists
vote.weekend; // true
vote.isTest; // dashboard test button
vote.query; // { ref: 'partner' }
vote.raw; // untouched original body
});
lists.on('review', (review) => {
review.rating; // 1 to 5 when sent
review.content; // review text
});
lists.on('comment', onComment);
lists.on('rating', onRating);
lists.on('test', (vote) => console.log('test from', vote.listId));
lists.on('statsPosted', (report) => console.log(report.posted, 'lists updated'));
lists.on('error', (error) => console.error(error.message));| Event | Payload | Fired when |
|---|---|---|
vote | UniversalVote | A user votes on any list |
comment | UniversalComment | A comment arrives |
review | UniversalComment | A review arrives |
rating | UniversalComment | A rating arrives |
test | UniversalVote | A list dashboard sends a test |
statsPosted | PostReport | After each postStats fan-out |
status | StatusBoard | After each status refresh |
raw | ParsedWebhook | Every parsed webhook |
request | RequestLog | Every http request received |
error | Error | Bad auth, invalid json, internal errors |
Using your own http framework
// express, fastify, whatever you already run
app.post('/webhooks/:list', (req, res) => {
lists.ingestWebhook(req.params.list, req.body);
res.sendStatus(200);
});Vote announcements
Realtime vote posts to Discord webhooks - text, embed or Components V2.
The VoteAnnouncer broadcasts every incoming vote to Discord channel webhooks (and any external https endpoint) in realtime. It is DISABLED by default - nothing leaves your process until you pass `enabled: true`. It is built directly on the Discord execute-webhook REST endpoint with plain fetch: no discord.js, no Eris, works inside any framework or none.
wire it up
const lists = new Botlists({
client,
announcer: {
enabled: true, // required - off by default
format: 'embed-v2', // 'text' | 'embed' | 'embed-v2'
webhooks: [process.env.VOTE_WEBHOOK_URL!], // Discord channel webhooks
external: ['https://api.example.com/hooks/votes'], // JSON { source, event, list, vote }
username: 'Vote Alerts', // optional webhook identity overrides
color: 0x5865f2,
links: [{ label: 'Vote Again', url: 'https://top.gg/bot/YOUR_BOT/vote', emoji: '🗳️' }],
announceTestVotes: false, // dashboard test deliveries: ignore by default
},
});three render formats
// 'text' - plain message content from a {placeholder} template
{ format: 'text', template: '🗳️ {voter} voted on {list}!' }
// 'embed' - classic rich embed (title, description, color, timestamp, thumbnail)
{ format: 'embed' }
// 'embed-v2' - Components V2: section + avatar accessory, separator,
// link buttons; the IS_COMPONENTS_V2 flag is set for you
{ format: 'embed-v2' }| Option | Default | What it does |
|---|---|---|
enabled | false | master switch for wiring inside Botlists (standalone instances are always active) |
format | 'embed' | render style: text / embed / embed-v2 |
webhooks | [] | Discord channel webhook urls (discord.com, discordapp.com, canary, ptb) |
external | [] | any https endpoint - receives { source, event, list, vote } JSON |
username / avatarUrl | webhook default | override the posting identity |
botToken | undefined | read-only: resolves username/avatar via GET /users/@me so posts use the bot’s identity |
template | see below | {placeholder} text: {voter} {voterId} {list} {listId} {bot} {botId} {weight} {weekend} |
links | [] | link buttons appended to embed-v2 (max 5) |
customize | undefined | (vote, payload) => payload - full control, mutate or replace before sending |
announceTestVotes | false | also announce dashboard test deliveries |
minIntervalMs | 1000 | spacing between two sends to the SAME target (rate-limit safety) |
maxQueueSize | 500 | per-target queue bound; the oldest vote is dropped when full |
standalone use
import { Botlists, VoteAnnouncer } from '@potenfyrstudios/discord-botlists';
const announcer = new VoteAnnouncer({ format: 'embed-v2', webhooks: [url] });
announcer.on('delivered', (d) => console.log('sent to', d.target, d.status));
const lists = new Botlists({ client });
lists.on('vote', (vote) => announcer.announce(vote));Webhook security
How the server rejects fake votes, floods and brute force.
Anyone who discovers your webhook URL could POST fake votes. The server defends against that with secure defaults: secrets are required, per-IP rate limiting and brute-force lockout are always on, and HMAC payload signing is supported per list.
security options
const lists = new Botlists({
webhook: {
port: 8080,
secret: {
'top.gg': 'shared-secret-for-topgg',
'botlist.me': 'another-secret',
},
security: {
// HMAC-SHA256 signing keys per list. when present, requests from that
// list must carry a valid signature in x-signature-256 /
// x-hub-signature-256 / x-signature.
hmac: { 'top.gg': 'webhook-signing-key' },
// per ip rate limit (default 30/min). extra requests get 429.
rateLimit: { max: 30, windowMs: 60_000 },
// ban an ip after N consecutive auth failures (default 10),
// for M ms (default 15 minutes). banned ips get 403.
banAfterFailures: 10,
banDurationMs: 15 * 60_000,
// only these lists may POST at all. others get 403.
allowedLists: ['top.gg', 'botlist.me', 'discordbotlist.com'],
// set true behind nginx/cloudflare so rate limits use the real ip.
trustProxy: true,
// only for local testing: accept unsigned posts.
requireSecret: false,
},
},
});| Threat | Defense | Response |
|---|---|---|
Fake votes (no secret) | Secret required on every POST | 401 |
Replayed/forged payloads | top.gg v1 signature or HMAC-SHA256 check | 401 |
Captured delivery replayed later | v1 timestamp window (10 min) | 401 |
Secret brute force | Failure counter per ip | 403 ban after 10 |
Request floods | Per ip token bucket | 429 + Retry-After |
Giant payloads | 512 KB body limit | 413 |
Unlisted sources | allowedLists check | 403 |
top.gg v1 signed deliveries
top.gg migrated its webhooks: instead of the shared password in the Authorization header, every v1 delivery carries `x-topgg-signature: t=<unix seconds>,v1=<hex>` where v1 is HMAC-SHA256 of `<t>.<rawBody>` keyed with your whs_-prefixed webhook secret. The SDK verifies this scheme automatically whenever the `top.gg` secret is configured - keep using the same secret, nothing else changes. The legacy Authorization header (and the other lists’ signature headers) keep working alongside it.
verify a v1 delivery inside your own framework route
// With headers passed, ingest() enforces transport auth itself and returns
// null on a bad signature. Without headers it trusts your framework's auth
// and only parses.
const parsed = lists.webhook.ingest('top.gg', req.body, {
headers: { 'x-topgg-signature': req.headers['x-topgg-signature'] },
});
if (!parsed) return res.status(401).end();The v1 payload is wrapped ({"vote":{"userId":..,"botId":..,"type":"vote"|"test"}}). The SDK flattens it transparently: your `vote` event still receives voterId, botId and a `raw` field holding the original enveloped body. Dashboard test deliveries (type "test") arrive on the `test` event with isTest: true.
Fetching data
fetchBot, fetchVotes, hasVoted, searchBots, widgets.
const bot = await lists.fetchBot('discord.bots.gg', '557628352828014614');
bot.name; // 'Ticket Tool'
bot.prefix; // '$ (Customizable)'
bot.library; // 'discord.js'
const votes = await lists.fetchVotes('top.gg'); // number | null
const voted = await lists.hasVoted('top.gg', userId); // boolean | null
const found = await lists.searchBots('botlist.me', 'music', 5);
lists.widgetUrl('top.gg'); // widget image url
lists.viewBotUrl('top.gg'); // listing page urlUniversal parser
One shape for every list response.
Lists name the same concept ten different ways: server_count, guildCount, guilds, servers, count. The parser reads each field in priority order, including nested paths like stats.server_count, and returns UniversalBot:
import { UniversalParser } from '@potenfyrstudios/discord-botlists';
const parser = new UniversalParser();
const bot = parser.parseBot(listRecord, rawApiResponse);
interface UniversalBot {
listId: string; listName: string;
id: string; name: string;
avatar: string | null;
owners: string[];
serverCount: number | null;
votes: number | null; monthlyVotes: number | null;
certified: boolean | null;
ratings: { average: number | null; count: number | null };
tags: string[];
raw: unknown; fetchedAt: number;
// ...description, invite, prefix, library, github, supportServer
}arrays and votes only
const many = parser.parseBots(list, rawArray);
const justVotes = parser.parseVotes(list, rawResponse);Status checking
Latency, uptime state, dead list detection.
const board = await lists.refreshStatus();
board.summary; // { live: 33, deprecated: 0, shutdown: 0, unknown: 0 }
board.entries[0];
// { listId, listName, website, state, httpStatus, latencyMs, lastChecked }
// probe, print the unicode console table, get github issue links for dead lists
await lists.checkAndReportStatus();| State | Meaning | What happens |
|---|---|---|
live | Website answered HTTP < 500 | Stays in the registry |
deprecated | Superseded or announced end of life | PR opens to remove it |
shutdown | Unreachable or parked domain | PR opens to remove it |
unknown | 5xx answers, list is struggling | Kept, watched closely |
Custom lists
Self-hosted or missing lists.
const lists = new Botlists({
lists: [{
id: 'my-list.dev',
name: 'My List',
website: 'https://my-list.dev',
apiPost: 'https://api.my-list.dev/bots/:id/stats',
postField: 'server_count',
postMethod: 'POST',
authHeader: 'Authorization',
tokenEnvKey: 'DBL_MYLIST',
webhook: { header: 'Authorization', voterField: 'user_id', eventField: null },
supports: { post: true, get: false, widget: false, webhook: true },
apiDocs: null, apiGet: null, viewBot: null, widget: null,
shardField: null, shardIdField: null, shardsArrayField: null,
}],
});Custom lists work everywhere built-in ones do: posting, webhooks, parsing, status probing and the env token pattern. Request built-in support for any live list through the list-request issue template.
Rate limit safety
How the SDK stays polite to every API.
- Stats posts: 1 s gap between lists by default (postSpacingMs), one polite retry on 429 honouring Retry-After when it is ≤ 30 s, and ±5% jitter on the auto-post timer so fleets never hit lists in lockstep.
- top.gg shards quirk: an EMPTY shards array zeroes your published server_count on top.gg - the SDK omits the field entirely unless the bot actually reports shard data.
- Vote announcer: sends to the same Discord webhook are spaced ≥ 1 s apart (minIntervalMs), a 429 with a small Retry-After is waited out once, and per-target queues are bounded (maxQueueSize 500, oldest dropped) so a stalled webhook can never grow memory. Timers are unref'd and only alive while a delivery is pending.
- BotBlock mode: one request total, never retried inside their 120 s window.
- Status probes: max 8 concurrent HEAD requests, browser user agent, board cached for 5 minutes.
- Fetches: token header only on endpoints that need it, public reads stay unauthenticated.
- Webhooks (inbound): push based, so there is zero polling traffic against any list API.
API Reference
Every class, method and type.
class Botlists extends EventEmitter
| Method | Returns | Description |
|---|---|---|
postStats(stats?, opts?) | Promise<PostReport> | Post to every tokened list, filter with only or skip |
postStatsTo(list, stats?) | Promise<PostResult> | Post to one list |
postViaBotBlock(stats?) | Promise<PostReport> | Single BotBlock fan-out |
startWebhooks(port?) | Promise<string> | Start the realtime server, returns address |
stopWebhooks() | Promise<void> | Stop the server |
ingestWebhook(list, body, isTest?) | void | Feed a webhook from your own framework |
fetchBot(list, botId?) | Promise<UniversalBot> | Normalized bot data from one list |
fetchVotes(list, botId?) | Promise<number | null> | Vote count on one list |
hasVoted(list, userId) | Promise<boolean | null> | User vote check where supported |
searchBots(list, query, limit?) | Promise<UniversalBot[]> | Search a list directory |
refreshStatus(force?) | Promise<StatusBoard> | Probe every list |
checkAndReportStatus(force?) | Promise<StatusBoard> | Probe, print table, issue links |
widgetUrl(list, botId?) | string | null | Widget image url |
viewBotUrl(list, botId?) | string | null | Listing page url |
startAutoPost(intervalMs?, stats?) | void | Periodic posting, default 30 min |
stopAutoPost() | void | Stop periodic posting |
class VoteWebhookServer extends EventEmitter
| Member | Description |
|---|---|
start() / stop() | Control the http server |
ingest(listQuery, body, opts) | Parse and emit without a socket |
address | http://host:port/path |
isRunning | Server state |
class UniversalParser
| Method | Returns |
|---|---|
parseBot(list, payload) | UniversalBot |
parseBots(list, payload) | UniversalBot[] |
parseVotes(list, payload) | number | null |
parseVoteWebhook(list, body, isTest?) | UniversalVote |
parseCommentWebhook(list, body, kind?) | UniversalComment |
class ConsoleReport (static)
| Method | Returns |
|---|---|
table(board) | Unicode status table string |
issueUrls(dead, repo?) | Prefilled GitHub issue urls per dead list |
class StatusChecker (static)
| Method | Returns |
|---|---|
toMarkdown(board) | Markdown table for READMEs |
Errors
BotlistsError carries listId, status and retryAfter so you can react per list. resolveList throws on ambiguous matches and returns null on unknown ids.
Migrating from v1
Old class to new SDK in five minutes.
| discord-botlists 1.x | 2.0.0 |
|---|---|
new BotLists(webhook, data, port, ip, redirect) | new Botlists({ webhook: { port, path, secret } }) |
botlists.start() | await lists.startWebhooks() |
on('vote', (name, token) => ...) | on('vote', (vote) => vote.voterId) |
express dependency | Zero dependencies, node:http |
axios dependency | Native fetch with retry-after handling |
manual botlists.json editing | Generated registry, auto pruned by status sync |