discord-botlists.

discord-botlists docs · v1.0.3 ·

Documentation

Every feature, scenario and type: straight from the SDK surface. Or press ⌘K to jump anywhere.

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.
Infov1.0.3 is a complete rewrite. The old express based class from 1.x is gone; see the migration section if you are coming from v1.

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
RequirementMinimumNotes
Node.js18.0.0uses global fetch and node:http
Bun1.1.0fully supported, recommended for tests
TypeScript5.0optional, types ship in the package
discord.js / Erisoptionalpass your client to auto collect stats
TipThe package never installs express, axios or dotenv. Everything runs on the Node standard library.

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.

FrameworkHow to useAuto collection
discord.jsnew Botlists({ client })guilds.cache.size, shard data
Erisnew Botlists({ client })guilds Map size
Oceanicnew Botlists({ client })guilds Map size
Other frameworksnew Botlists({ statsProvider })you provide it
No frameworknew Botlists({ statsProvider }) or explicit statsyou 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 });
TipEverything except auto stats collection (webhooks, parser, status checks, posting) never touches your client object - it is plain HTTP and Node builtins.

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.
OptionTypeDefaultDescription
botIdstringclient.user.idYour bot application id
clientBotClientLikenulldiscord.js, Eris or Oceanic client for auto stats
statsProviderStatsProvidernullProvide stats yourself: any framework or none
tokensRecord<string,string>env DBL_*Per list tokens, overrides env
authHeadersRecord<string,string>per list defaultOverride the auth header name per list
listsBotlistRecord[][]Add custom or self-hosted lists
webhookWebhookOptions{}Webhook server config
fetchOptionsFetchOptionstimeout 10s, 2 retriesApplied to every request
disableStatusCheckbooleanfalseSkip pre-request status lookups
startupStatusCheckbooleanfalseProbe 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:

ListBody fieldAuth header
top.ggserver_countAuthorization
discord.bots.ggguildCountAuthorization
discordbotlist.comguildsAuthorization
botlist.meserver_countauthorization
disforge.comserversAuthorization
discordlist.ggcountAuthorization
discord.rovelstars.comcountauthorization

auto posting on an interval

lists.startAutoPost(30 * 60 * 1000); // every 30 minutes, minimum 60s
lists.stopAutoPost();
NoteBotBlock allows one successful request per 120 seconds. postViaBotBlock never retries inside that window; the SDK reports the 429 with retryAfter instead.

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));
EventPayloadFired when
voteUniversalVoteA user votes on any list
commentUniversalCommentA comment arrives
reviewUniversalCommentA review arrives
ratingUniversalCommentA rating arrives
testUniversalVoteA list dashboard sends a test
statsPostedPostReportAfter each postStats fan-out
statusStatusBoardAfter each status refresh
rawParsedWebhookEvery parsed webhook
requestRequestLogEvery http request received
errorErrorBad 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);
});
TipWebhook payloads are normalized per list using the registry webhook hints: the header each list signs with, the voter id field and whether the list uses an event type field like top.gg v1.

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' }
OptionDefaultWhat it does
enabledfalsemaster 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 / avatarUrlwebhook defaultoverride the posting identity
botTokenundefinedread-only: resolves username/avatar via GET /users/@me so posts use the bot’s identity
templatesee below{placeholder} text: {voter} {voterId} {list} {listId} {bot} {botId} {weight} {weekend}
links[]link buttons appended to embed-v2 (max 5)
customizeundefined(vote, payload) => payload - full control, mutate or replace before sending
announceTestVotesfalsealso announce dashboard test deliveries
minIntervalMs1000spacing between two sends to the SAME target (rate-limit safety)
maxQueueSize500per-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));
TipRate-limit safety is on by default: sends to the same target are spaced ≥ 1 s apart, a 429 with a small Retry-After is waited out exactly once, queues are bounded so a webhook outage can never grow memory, and scheduler timers are unref'd - zero idle resource usage.

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,
    },
  },
});
ThreatDefenseResponse
Fake votes (no secret)Secret required on every POST401
Replayed/forged payloadstop.gg v1 signature or HMAC-SHA256 check401
Captured delivery replayed laterv1 timestamp window (10 min)401
Secret brute forceFailure counter per ip403 ban after 10
Request floodsPer ip token bucket429 + Retry-After
Giant payloads512 KB body limit413
Unlisted sourcesallowedLists check403

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.

NoteThe server throws at start() when requireSecret is true (the default) and no secret was configured. This is intentional: an open webhook endpoint will receive fake votes within hours of going public.

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 url
Notetop.gg and voidbots require a token even for reads. Lists without a public bot endpoint throw a BotlistsError with listId attached; catch it and fall back.

Universal 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();
StateMeaningWhat happens
liveWebsite answered HTTP < 500Stays in the registry
deprecatedSuperseded or announced end of lifePR opens to remove it
shutdownUnreachable or parked domainPR opens to remove it
unknown5xx answers, list is strugglingKept, watched closely
InfoThe hourly GitHub workflow updates the README table and website on main directly. Only deprecated or shutdown detections open a pull request, so registry changes always get a human review.

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

MethodReturnsDescription
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?)voidFeed 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 | nullWidget image url
viewBotUrl(list, botId?)string | nullListing page url
startAutoPost(intervalMs?, stats?)voidPeriodic posting, default 30 min
stopAutoPost()voidStop periodic posting

class VoteWebhookServer extends EventEmitter

MemberDescription
start() / stop()Control the http server
ingest(listQuery, body, opts)Parse and emit without a socket
addresshttp://host:port/path
isRunningServer state

class UniversalParser

MethodReturns
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)

MethodReturns
table(board)Unicode status table string
issueUrls(dead, repo?)Prefilled GitHub issue urls per dead list

class StatusChecker (static)

MethodReturns
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.x2.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 dependencyZero dependencies, node:http
axios dependencyNative fetch with retry-after handling
manual botlists.json editingGenerated registry, auto pruned by status sync
InfoEvent payloads changed from positional arguments to a single normalized object. Everything you read from vote.raw is still the untouched original body, so old integrations keep working during migration.