@potenfyrstudios/discord-botlists
v1.0.3
Published
Universal multi-botlist SDK for Discord bots: post stats to 27 verified live lists, realtime vote webhooks, ratings and a universal data parser with full TypeScript types
Downloads
523
Maintainers
Readme
@potenfyrstudios/discord-botlists: the universal multi-botlist SDK for Discord bots: post stats to 27 verified live lists, get votes in realtime, parse every API into one shape.
Docs · Examples · npm · Issues · Live status
Why discord-botlists
Posting your bot's stats to every list and handling every list's webhook format yourself is weeks of glue code. This package does all of it with zero runtime dependencies:
- Stats posting to 27 verified live lists, each with its correct wire format, auth header and endpoint, learned from every list's own docs plus the BotBlock directory. Dead lists are pruned automatically by the hourly status sync.
- Realtime vote, comment and review events through a built-in webhook server. No polling, no delay, no express dependency.
- Universal parser that normalizes any list's bot data into one
UniversalBotobject so you never juggleserver_countvsguildCountvsguildsvscountagain. - Status tracking: automated hourly probes detect deprecated or shut down lists, with latency and HTTP state, synced into this README and the docs site.
- Fully typed: strict TypeScript, generic event emitter, autocomplete for every option.
- Lightweight: zero dependencies, works on Node 18+ and Bun.
The full documentation lives at botlists.docs.potenfyr.in: API reference, examples and the live status board. This README mirrors the same content.
Installation
npm install @potenfyrstudios/discord-botlists
# or
bun add @potenfyrstudios/discord-botlists
# or
pnpm add @potenfyrstudios/discord-botlistsRequirements: Node.js 18+ or Bun 1.1+. No peer dependencies.
Works with every Discord framework, or none at all:
| Framework | Auto stats collection |
| --- | --- |
| discord.js | pass client - reads guilds.cache.size, shard info |
| Eris | pass client - reads the guilds Map |
| Oceanic | pass client - reads the guilds Map |
| Any other client | pass client if it exposes guilds |
| Anything else / no framework | use statsProvider or pass stats explicitly |
// framework-less usage: stats provided by you
const lists = new Botlists({
statsProvider: () => ({
serverCount: shardManager.totalGuilds,
shardCount: shardManager.shardCount,
}),
});
// or per call
await lists.postStats({ serverCount: myGuildCount });The webhook server, parser, status checks and every other feature are framework independent - they use plain HTTP and Node builtins.
Quick start
import { Client } from 'discord.js';
import { Botlists } from '@potenfyrstudios/discord-botlists';
const client = new Client({ intents: ['Guilds'] });
const lists = new Botlists({
client,
tokens: {
'top.gg': process.env.TOPGG_TOKEN,
'discordbotlist.com': process.env.DBL_TOKEN,
},
webhook: {
port: 8080,
path: '/discord-botlists',
secret: process.env.WEBHOOK_SECRET,
},
});
client.once('ready', async () => {
// 1. post stats to every list you gave a token for
const report = await lists.postStats();
console.log(`posted to ${report.posted} lists`);
// 2. start realtime vote webhooks
await lists.startWebhooks();
});
// 3. react to votes the moment they happen
lists.on('vote', (vote) => {
console.log(`${vote.voterId} voted on ${vote.listName} (x${vote.weight})`);
// give the voter a bonus role here
});
lists.on('review', (review) => {
console.log(`new review on ${review.listName}: ${review.content}`);
});
client.login(process.env.DISCORD_TOKEN);Point each botlist's webhook URL at https://your-domain:8080/discord-botlists/<list-id> and votes arrive as typed events instantly. Per-list wire formats, env variable names and more live examples: docs · examples.
top.gg v1 signed webhooks
top.gg no longer sends the shared password in the Authorization header. Every top.gg v1 delivery carries x-topgg-signature: t=<unix seconds>,v1=<hex>, the HMAC-SHA256 of <t>.<rawBody> keyed with your whs_... webhook secret from the top.gg dashboard. The SDK verifies this automatically - just keep the secret configured for top.gg (the same field you already had). The legacy Authorization header and other signature schemes keep working for every other list.
The v1 payload is wrapped ({"vote":{"userId":..,"botId":..,"type":"vote"|"test"}}); the SDK flattens it so your vote handler is unchanged, preserves the original body on vote.raw, and routes dashboard test deliveries to the test event with isTest: true. Feeding bodies through lists.webhook.ingest() from your own framework route? Pass the delivery headers and ingest enforces the signature check for you (returns null on failure); omit them and it trusts your framework's auth.
Announce votes to Discord in realtime (text / embed / embed-v2)
Disabled by default - opt in and every incoming vote is broadcast to your Discord channel webhooks (and optionally external endpoints) through the raw Discord execute-webhook REST API, no Discord library required:
const lists = new Botlists({
client,
announcer: {
enabled: true,
format: 'embed-v2', // 'text' ({placeholder} template) | 'embed' | 'embed-v2' (Components V2)
webhooks: [process.env.VOTE_WEBHOOK_URL!], // Discord channel webhooks
external: ['https://api.example.com/hooks/votes'], // JSON: { source, event, list, vote }
links: [{ label: 'Vote Again', url: 'https://top.gg/bot/YOUR_BOT/vote', emoji: '🗳️' }],
},
});Rate-limit safe by default: ≥ 1 s spacing per target (minIntervalMs), one polite Retry-After wait on 429, bounded queues (maxQueueSize, oldest dropped - a stalled webhook can never grow memory) and unref'd timers that only exist while a delivery is pending. Full control via customize(vote, payload), identity overrides via username / avatarUrl / botToken, and a standalone VoteAnnouncer class for custom pipelines.
Full API
class Botlists
| Method | Returns | Description |
| --- | --- | --- |
| postStats(stats?, opts?) | Promise<PostReport> | Post to every list you have a token for. opts.only / opts.skip filter by list id. |
| postStatsTo(list, stats?) | Promise<PostResult> | Post to one list. |
| postViaBotBlock(stats?) | Promise<PostReport> | One request to botblock.org that fans out to all lists. |
| startWebhooks(port?) | Promise<string> | Start the realtime webhook server, returns its address. |
| stopWebhooks() | Promise<void> | Stop the webhook server. |
| ingestWebhook(list, body, isTest?) | void | Feed a webhook body from your own express/fastify app. |
| fetchBot(list, botId?) | Promise<UniversalBot> | Fetch and normalize a bot from one list. |
| fetchVotes(list, botId?) | Promise<number \| null> | Current vote count on one list. |
| hasVoted(list, userId) | Promise<boolean \| null> | Check if a user voted (lists that expose it). |
| searchBots(list, query, limit?) | Promise<UniversalBot[]> | Search a list's directory. |
| refreshStatus(force?) | Promise<StatusBoard> | Probe all lists: state, latency, HTTP. |
| checkAndReportStatus(force?) | Promise<StatusBoard> | Probe + console table + issue links. |
| widgetUrl(list, botId?) | string \| null | Widget image URL on a list. |
| viewBotUrl(list, botId?) | string \| null | Public bot page on a list. |
| startAutoPost(intervalMs?, stats?) | void | Re-post stats periodically (default 30 min). |
| stopAutoPost() | void | Stop the auto poster. |
A list can be referenced by id ('top.gg'), name ('Discord Bots'), hostname ('discord.bots.gg') or shorthand ('topgg', 'voidbots', 'radarcord').
Also exported: UniversalParser, StatusChecker, ConsoleReport, BotlistsError, EVENTS, and the subpath exports @potenfyrstudios/discord-botlists/webhooks (VoteWebhookServer) and /lists (the raw registry).
Realtime events
| Event | Payload | Fired when |
| --- | --- | --- |
| vote | UniversalVote | A user votes for your bot on any list. |
| review | UniversalComment | A review is posted. |
| comment | UniversalComment | A comment is posted. |
| rating | UniversalComment | A rating is posted. |
| test | UniversalVote | A list dashboard sends a webhook test. |
| statsPosted | PostReport | After each postStats fan-out. |
| status | StatusBoard | After each status refresh. |
| raw | ParsedWebhook | Every parsed webhook, for custom handling. |
| request | { method, url, status, listId } | Every HTTP request the server receives. |
| error | Error | Bad auth, invalid JSON, internal errors. |
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; // false
vote.query; // { ref: 'partner' } if the vote link had query params
vote.raw; // the untouched original body
});UniversalBot: one shape for every list
Whatever list you call fetchBot on, you always get:
interface UniversalBot {
listId: string; // 'top.gg'
listName: string; // 'Discord Bot List'
id: string; // '432161800760442880'
name: string; // 'Rythm'
avatar: string | null;
description: string | null;
owners: string[]; // ['160105994217586689']
serverCount: number | null;
shardCount: number | null;
votes: number | null; // total votes / points
monthlyVotes: number | null;
certified: boolean | null;
website: string | null;
github: string | null;
supportServer: string | null;
invite: string | null;
prefix: string | null;
library: string | null;
tags: string[];
ratings: { average: number | null; count: number | null };
raw: unknown; // original payload, always kept
fetchedAt: number; // unix ms
}Webhook security
Anyone who discovers your webhook URL could POST fake votes. The server defends against that by default:
- Secret required: POSTs without a valid secret are rejected with 401. The server refuses to start if you never configure a secret (override with
security.requireSecret = falsefor local tests only). - Per-list secrets:
secret: { 'top.gg': '...', 'botlist.me': '...' }- each list only passes with its own value. - HMAC-SHA256 payload signing: for lists that sign payloads, pass
security.hmackeys and the server verifies the signature fromx-signature-256/x-hub-signature-256/x-signatureheaders. A wrong signature is rejected even if the secret matches. - Per-IP rate limiting: default 30 requests/minute, extra requests get 429 +
Retry-After. - Brute-force lockout: 10 consecutive auth failures bans the IP for 15 minutes (403).
- List allowlist:
security.allowedListsrestricts which lists may POST at all. - Body limit: payloads over 512 KB are dropped (413).
security.trustProxy = trueif you run behind nginx/Cloudflare so rate limits key off the real client IP.
const lists = new Botlists({
webhook: {
port: 8080,
secret: {
'top.gg': 'shared-secret-for-topgg',
'botlist.me': 'another-secret',
},
security: {
// sign keys per list, checked against x-signature-256 etc.
hmac: { 'top.gg': 'webhook-signing-key' },
rateLimit: { max: 30, windowMs: 60_000 },
banAfterFailures: 10,
allowedLists: ['top.gg', 'botlist.me', 'discordbotlist.com'],
trustProxy: true, // behind a reverse proxy
},
},
});Tokens: three ways, pick what fits
# 1. env vars (recommended). Pattern: DBL_<LISTID-UPPERCASED>
DBL_TOP.GG=eyJ... # top.gg token
DBL_DISCORDBOTLIST.COM=... # discordbotlist.com token
DBL_VOIDBOTS.NET=...Env keys are mapped onto list ids automatically (DBL_TOPGG / DBL_TOP.GG → top.gg), so you never have to hand-translate names. Dots, underscores and casing don't matter.
// 2. constructor map
const lists = new Botlists({ tokens: { 'top.gg': 'eyJ...' } });
// 3. mixed: env is the base, the map overrides per keylist.tokenEnvKey tells you the env name for every list at runtime. See .env.example for the full key list.
Posting stats, including shards
await lists.postStats({
serverCount: client.guilds.cache.size,
shardCount: client.shard?.count,
shards: await client.shard?.fetchClientValues('guilds.cache.size'),
});
// only some lists
await lists.postStats({ serverCount: 100 }, { only: ['top.gg', 'botlist.me'] });
// one list
await lists.postStatsTo('radarcord', { serverCount: 100 });
// single BotBlock request for all lists (counts as 1 request to botblock)
await lists.postViaBotBlock({ serverCount: 100 });Every list gets its correct body shape automatically: server_count for top.gg, guildCount for discord.bots.gg, guilds for discordbotlist.com, servers for disforge, and so on for all 27.
Rate limit safety
- Posts are throttled with a 250 ms gap per list; HTTP layer retries 408/429/5xx with backoff, and strict requests honour
Retry-After(≤ 30 s) once before failing. - BotBlock mode sends one request total (their own limit is 1 per 120 s, the SDK will not retry it faster).
- Status probes use 8 parallel HEAD requests max (GET fallback for hosts that reject HEAD), browser UA, board cached 5 minutes.
fetchBot/searchBotsadd your token only when required, public endpoints stay unauthenticated.
Custom / self-hosted lists
const lists = new Botlists({
lists: [{
id: 'my-list.dev',
name: 'My Self Hosted List',
website: 'https://my-list.dev',
apiPost: 'https://api.my-list.dev/bots/:id/stats',
postField: 'server_count',
postMethod: 'POST',
authHeader: 'Authorization',
apiDocs: null, apiGet: null, viewBot: null, widget: null,
shardField: null, shardIdField: null, shardsArrayField: null,
tokenEnvKey: 'DBL_MYLIST',
webhook: { header: 'Authorization', voterField: 'user_id', eventField: null },
supports: { post: true, get: false, widget: false, webhook: true },
}],
});Custom lists work everywhere built-in ones do: posting, webhooks, parsing, status.
Supported lists (27 verified live)
The generated registry (src/data/lists.generated.ts, reproducible from the BotBlock snapshot) only contains lists that answered during the latest status audit. Shutdown and deprecated lists are removed automatically. The September 2026 docs audit pruned 8 domains that now serve registrar parking or squatter pages despite answering HTTP 200 (blist.xyz, botlist.co, botsdatabase.com, discord.services, discordbot.world, motiondevelopment.top, space-bot-list.xyz, topcord.xyz) and added topbot.gg and discordforge.org:
top.gg | discordbotlist.com | discord.bots.gg | botlist.me | discords.com | voidbots.net | vcodes.xyz | radarcord.net | discordlist.gg | disforge.com | disq.ink | dlist.space | cybralist.com | discord.rovelstars.com | yabl.xyz | justdiscord.org | omniplex.gg | discover.fluxpoint.dev | bots.discordlabs.org | discordbotlist.xyz | stellarbotlist.com | carbonitex.net | discord.place | topbot.gg | discordforge.org
Each record carries: endpoint URLs, wire field names, shard field names, auth header, widget and view URLs, webhook format hint and env var key.
Missing a list? Open a list request with its name, API docs link and a maintainer contact: live lists get added within days.
Live status
Last sync: 2026-09-21 | 🟢 27 live | 🟡 0 deprecated | 🔴 0 shutdown | ⚪ 0 unknown
| List | Status | Latency | HTTP | Last checked (UTC) | | --- | --- | --- | --- | --- | | Botlist.me | 🟢 live | 899 ms | 200 | 2026-09-21 02:32 | | Discord Labs | 🟢 live | 530 ms | 200 | 2026-09-21 02:32 | | Bots on Discord | 🟢 live | 1593 ms | 200 | 2026-09-21 02:32 | | Carbonitex | 🟢 live | 1140 ms | 200 | 2026-09-21 02:32 | | Cybralist | 🟢 live | 554 ms | 200 | 2026-09-21 02:32 | | Discord Bots | 🟢 live | 747 ms | 200 | 2026-09-21 02:32 | | discord.place | 🟢 live | 157 ms | 403 | 2026-09-21 02:32 | | Rovel Discord List | 🟢 live | 1546 ms | 200 | 2026-09-21 02:32 | | Discord Bot List | 🟢 live | 671 ms | 200 | 2026-09-21 02:32 | | Discord Bot List XYZ | 🟢 live | 814 ms | 200 | 2026-09-21 02:32 | | Discord Extreme List | 🟢 live | 1225 ms | 200 | 2026-09-21 02:32 | | DiscordForge | 🟢 live | 524 ms | 200 | 2026-09-21 02:32 | | dlist.gg | 🟢 live | 473 ms | 200 | 2026-09-21 02:32 | | Bots for Discord | 🟢 live | 463 ms | 200 | 2026-09-21 02:32 | | Fluxpoint Discover | 🟢 live | 1072 ms | 200 | 2026-09-21 02:32 | | Disforge | 🟢 live | 2384 ms | 200 | 2026-09-21 02:33 | | DisQ | 🟢 live | 1225 ms | 200 | 2026-09-21 02:32 | | DList.Space | 🟢 live | 235 ms | 200 | 2026-09-21 02:32 | | JustDiscord | 🟢 live | 966 ms | 200 | 2026-09-21 02:32 | | Omniplex | 🟢 live | 1156 ms | 200 | 2026-09-21 02:32 | | Radarcord | 🟢 live | 1226 ms | 200 | 2026-09-21 02:32 | | Stellar Bot List | 🟢 live | 971 ms | 200 | 2026-09-21 02:32 | | Discord Bot List | 🟢 live | 158 ms | 403 | 2026-09-21 02:32 | | TopBot | 🟢 live | 707 ms | 200 | 2026-09-21 02:32 | | vCodes | 🟢 live | 931 ms | 200 | 2026-09-21 02:32 | | Void Bots | 🟢 live | 571 ms | 200 | 2026-09-21 02:32 | | Yet Another Bot List | 🟢 live | 167 ms | 200 | 2026-09-21 02:32 |
The table above is regenerated hourly by scripts/status-sync.ts (workflow: status-sync.yml). A list is marked:
- 🟢 live: website answered with HTTP < 500.
- 🟡 deprecated: superseded or announced end of life.
- 🔴 shutdown: unreachable, or domain is parked/dead.
When a list turns deprecated or shutdown, the workflow opens a pull request that removes it from the post registry, so a human always approves registry changes. Pure latency/uptime refreshes land on main directly.
Testing
bun install
bun test # unit tests, no network
bun scripts/test-live.ts # live integration, reads .env (optional)Everything also runs in Docker (the pinned oven/bun:1 image - no local toolchain needed):
docker compose -f docker-compose.test.yml run --rm tests # typecheck + full suite
docker compose -f docker-compose.test.yml run --rm build # tsc build
TOPGG_TOKEN=... TOPGG_WEBHOOK_SECRET=... \
docker compose -f docker-compose.test.yml run --rm live # real top.gg round-tripFor the live test: cp .env.example .env, fill in tokens for the lists you use (all optional), and run bun scripts/test-live.ts. It fetches real data, posts real stats (server count 1) and simulates a vote webhook, printing PASS/FAIL per action. Lists without tokens are skipped, nothing hard fails. scripts/verify-topgg.mjs is the production-style check: real stats POST + round-trip, a genuinely signed top.gg v1 delivery through the ingest path, and announcer validation - TOPGG_TOKEN=... TOPGG_WEBHOOK_SECRET=... node scripts/verify-topgg.mjs <serverCount>.
Registry maintenance
# refresh the BotBlock snapshot and regenerate the registry
curl -s https://botblock.org/api/lists > scripts/snapshot/botblock-lists.json
python3 scripts/snapshot/build_lists.py
bun test # verify nothing brokeContributing
PRs welcome; see CONTRIBUTING.md for setup, commands and conventions. In short:
- Add or fix list data in
scripts/snapshot/build_lists.py(manual overrides), not the generated file. - Run
bun testandbun run lint. - Keep the zero-dependency promise: no new runtime deps.
Found a vulnerability? Please report privately: see SECURITY.md.
Docs & links
- Documentation site (this repo's
docs/, deployed via GitHub Pages) - API reference · Examples · Status board
- npm package
- PotenFYR Studios | Website | Discord
License
Licensed under the Apache License 2.0 with the Commons Clause: free to fork, modify, use, and build around; not to be sold as a product. See LICENSE; the LICENSE file is authoritative. Botlist names and trademarks belong to their respective owners; see NOTICE.md.
Built by PotenFYR Studios · potenfyr.in · Part of the PotenFYR Studios open-source ecosystem.
⭐ Star History
Every public PotenFYR Studios repository on one live chart, served by star-history.com.
Contributing
Contributions make the open-source community such an amazing place to learn, inspire and create. Any contributions you make are greatly appreciated - see CONTRIBUTING.md and the good first issues. Security concerns: please use SECURITY.md (private vulnerability reporting), not public issues.
