Core Concepts
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.