On this page
- Overview
- Getting started
- Automate with an agent
- Authentication
- Tokens & pricing
- SMM panel API
- services
- add
- status
- balance
- cancel
- Platform API
- Catalog
- Account
- Two-factor & sign-in
- Preferences & alerts
- Campaigns
- Placing an order
- Editing an order
- Logs & analytics
- Username & proxy profiles
- Discord tokens
- Billing & payments
- Saved cart
- Internal surfaces
- Response shapes
- Site object
- Campaign object
- Campaign state
- Progress & refunds
- End-to-end example
- Webhooks
- Setting one up
- Events
- Payload & headers
- Receiving & retries
- Delivery log
- Security
- Errors
- Rate limits
- Limits & notes
Overview
Toplistbot exposes two HTTP APIs. Both are JSON over HTTPS, both spend the same token balance, and either one is enough to run campaigns without ever opening the dashboard.
SMM panel API
One Perfect Panel compatible endpoint. If your panel already speaks the standard SMM protocol, point it here and it works with no code changes.
Platform API
The REST API behind the dashboard: browse the catalog, create and steer campaigns, read per-action delivery logs, manage profiles, proxies and invoices.
Base URL
https://backend.toplistbot.com/api
https://backend.toplistbot.comReference below is written against these two hosts. Which one you use decides how you authenticate — see Authentication.
Getting started
From a new account to a running campaign in five steps. Everything below uses the SMM endpoint, which is the quickest way in; the platform API works the same way once you have a JWT.
Create an account
Sign up and verify your email. Verification credits your balance with 1 free token, so you can try a campaign before you spend anything.
Copy your API key
Open your dashboard and generate an API key. Treat it like a password — it spends your token balance. You can rotate it at any time, which immediately invalidates the old one.
Find the service you want
List every site you can order on. Each entry has a numeric service id and a rate in tokens per 1,000 actions. Note the id of the site you want to promote on.
cURLcurl -X POST https://backend.toplistbot.com/api/v2 -d "key=YOUR_API_KEY" -d "action=services"Place your first order
Send the service id, the URL your campaign should run against, and how many actions to run. The cost is deducted immediately and the response gives you an order id.
cURLcurl -X POST https://backend.toplistbot.com/api/v2 \ -d "key=YOUR_API_KEY" \ -d "action=add" \ -d "service=9" \ -d "link=https://arena-top100.com/index.php?a=in&u=yourserver" \ -d "quantity=1000"Track delivery
Poll the order id to see how much has been delivered. Once you are happy with the flow, wire the same calls into your own panel or scripts.
cURLcurl -X POST https://backend.toplistbot.com/api/v2 -d "key=YOUR_API_KEY" -d "action=status" -d "orders=184223"
Connecting a Perfect Panel instance
If you run Perfect Panel or compatible SMM panel software, you do not need to write any code — add Toplistbot as a provider with these settings and import the service list.
- API URL
- https://backend.toplistbot.com/api/v2
- API key
- YOUR_API_KEY
- HTTP method
- POST
Start with a small quantity on one site to confirm your link format is accepted before scaling up. A wrong link still costs tokens.
Automate with an AI agent
This page has a plain-text twin written for machines. Give an agent that URL and your API key and it has everything it needs: the full endpoint list, request and response shapes, pricing arithmetic, error codes and worked examples.
Machine-readable reference
One document, no authentication, no JavaScript. Fetch it, paste it into a prompt, or hand the URL to a tool that can browse.
https://toplistbot.com/llms.txtStarting prompt
Paste this into Claude or any agent that can make HTTP requests. Keep the key in an environment variable rather than in the message itself.
Read https://toplistbot.com/llms.txt — it is the complete Toplistbot API reference.
My API key is in the TOPLISTBOT_KEY environment variable. Using the API-key
surface (paths without the /api prefix):
1. list the sites in the catalog that cost under 20 tokens per 1,000 actions
2. tell me my token balance
3. propose a campaign for <my campaign URL> that fits a budget of <N> tokens
Do not place the order until I confirm the cost.An API key is the right credential for an agent: it never expires, it is unaffected by two-factor authentication, and rotating it from the dashboard revokes access instantly if you ever need to.
Authentication
There are two credentials, and which one you need depends on the path rather than the endpoint. Almost every endpoint is mounted twice.
The /api prefix decides the credential
The same handler sits behind both paths. Drop the /api prefix and the platform API accepts a long-lived API key; keep it and the endpoint expects a JWT from a login.
| Path | Credential | Use it for |
|---|---|---|
| /api/orders/getAll | JWT | Anything a human signs into |
| /orders/getAll | API key | Scripts, cron jobs, agents |
For automation, prefer the un-prefixed paths. There is no login, no expiry and no session to keep alive — one key does everything, and two-factor authentication never gets in the way.
API key
Send your key as a `key` field on any request — a query parameter, a form field, a JSON field, or an `Authorization: Bearer` header. Generate and rotate it from the dashboard. A GET to /api/v2 returns a status of ok and is a cheap way to check a key is live.
# any of these three carry the key
curl "https://backend.toplistbot.com/orders/getAll?key=YOUR_API_KEY"
curl -X POST https://backend.toplistbot.com/orders/pause -d "key=YOUR_API_KEY" -d "id=184223"
curl https://backend.toplistbot.com/orders/getAll -H "Authorization: Bearer YOUR_API_KEY"curl https://backend.toplistbot.com/api/v2?key=YOUR_API_KEYJWT
Log in to receive a token, then send it as a bearer token on /api routes. Tokens expire, so call /auth/refresh before they do. If the account has two-factor authentication on, login also needs a `two_factor_code`.
curl -X POST https://backend.toplistbot.com/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"..."}'{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
"token_type": "bearer",
"expires_in": 3600,
"user": { "id": 4211, "email": "[email protected]", "tokens": 528.41, "...": "..." }
}curl https://backend.toplistbot.com/api/orders/getAll \
-H "Authorization: Bearer YOUR_JWT"Calling from a browser
Every route answers with Access-Control-Allow-Origin set to a wildcard, so a page, a browser extension or a browser-based agent can call the API directly — no proxy of your own required. The warning above still stands: a key you ship to a browser is a key you have published, so this is for your own tools, not for a public page.
// Works from a page, an extension, or a browser-based agent.
const sites = await fetch(
'https://backend.toplistbot.com/orders/getAllWebsites'
).then(r => r.json())Your API key spends real token balance. Keep it server-side: anything shipped to a browser or committed to a repository should be treated as compromised and rotated from the dashboard.
Tokens & pricing
Campaigns are paid for in tokens, bought up front. Every site publishes a rate — the tokens it costs to run 1,000 campaign actions there — returned as `rate` by the services action.
cost_in_tokens = (rate * quantity) / 1000A site with a rate of 13 costs 13 tokens for 1,000 actions, so an order of 500 costs 6.5 tokens. The cost is deducted when the order is accepted, and cancelling refunds the unspent remainder.
The balance and status responses report a currency field of USD for Perfect Panel compatibility, but the value is a token balance, not dollars. Treat the number as tokens.
SMM panel API
One endpoint handles everything. Send an `action` field with each POST to choose the operation; every request also carries your `key`.
https://backend.toplistbot.com/api/v2| Action | Parameters |
|---|---|
services | — |
add | service, link, quantity, interval? |
status | orders |
balance | — |
cancel | orders |
`refill` and `refill_status` are accepted for compatibility and both answer "not implemented". Nothing here is refillable; re-order instead.
action=services
Lists every site you can order on, with its current rate and limits. Use the `service` id in your add calls.
curl -X POST https://backend.toplistbot.com/api/v2 \
-d "key=YOUR_API_KEY" \
-d "action=services"[
{
"service": 9,
"name": "arena-top100.com 1000 upvotes",
"type": "Default",
"category": "Votes",
"rate": 15,
"min": 1,
"max": 50000,
"refill": false,
"cancel": true
}
]`rate` is tokens per 1,000 actions. `min` is 1 and `max` is 50000 for every service.
action=add
Creates a campaign and immediately deducts its cost from your balance.
| Parameter | Type | Description |
|---|---|---|
keyrequired | string | Your API key. |
actionrequired | string | Must be `add`. |
servicerequired | integer | Service id from the services action. |
linkrequired | url | The URL the campaign runs against. Must be a valid URL. |
quantityrequired | integer | Number of actions to run, between 1 and 50000. |
interval | integer | Actions per hour. Defaults to 15, capped at 4000, and cannot exceed the site's own maximum. |
curl -X POST https://backend.toplistbot.com/api/v2 \
-d "key=YOUR_API_KEY" \
-d "action=add" \
-d "service=9" \
-d "link=https://arena-top100.com/index.php?a=in&u=yourserver" \
-d "quantity=1000" \
-d "interval=60"{
"order_id": 184223
}action=status
Returns progress for one or more orders. Pass a single id for a flat object, or a comma-separated list.
curl -X POST https://backend.toplistbot.com/api/v2 \
-d "key=YOUR_API_KEY" \
-d "action=status" \
-d "orders=184223"{
"charge": 13.5,
"start_count": 0,
"status": "Completed",
"remains": 1000,
"currency": "USD"
}With several ids the response is keyed by order id, and unknown or foreign orders return an error entry rather than failing the whole request.
{
"184223": { "charge": 13.5, "start_count": 0, "status": "Completed", "remains": 1000, "currency": "USD" },
"184224": { "error": "Incorrect order ID" }
}Read `remains`, not `status`
`status` is always the literal "Completed". The field exists because every Perfect Panel client demands it, and panels treat any other value as a refill candidate — which this platform does not offer. Progress lives in the numbers: `remains` is the accepted actions still to deliver, so `remains == 0` means the order has finished. `start_count` is what has been delivered so far and `charge` is what it has cost. Both are measured against the site's accept rate, so they count the actions you bought rather than raw attempts.
`status` and `cancel` accept at most 100 order ids per call. Batch rather than looping — one call with 100 ids is far cheaper for both sides than 100 calls.
action=balance
Returns your remaining token balance.
curl -X POST https://backend.toplistbot.com/api/v2 \
-d "key=YOUR_API_KEY" \
-d "action=balance"{
"balance": 528.41,
"currency": "USD"
}action=cancel
Stops an order and refunds the unspent remainder to your balance. Completed orders cannot be cancelled.
curl -X POST https://backend.toplistbot.com/api/v2 \
-d "key=YOUR_API_KEY" \
-d "action=cancel" \
-d "orders=184223,184224"[
{ "order": "184223", "cancel": 1, "refund": 4.5 },
{ "order": "184224", "cancel": { "error": "Incorrect order ID" } }
]Platform API
The same REST API the dashboard uses. Paths below are written in their API-key form, without the /api prefix. Prepend /api and swap the key for a JWT to use the session form; endpoints marked JWT exist only under /api.
Catalog & discovery
Public, no credential needed. getAllWebsites is the endpoint to start from — it carries the id, price, hourly ceiling and accept rate you need to price and shape an order.
- GET
/orders/getAllWebsitesPublicThe full catalog — every site with rates, limits and metadata - GET
/orders/getAllBasicWebsitesDetailsPublic20 random site names, for widgets and autocompletes - POST
/orders/getWebsiteDetailsByNamePublicOne site by its exact name - POST
/products/getSuggestionsPublicSites related to a set of ids - GET
/products/demand?days=30PublicHow much each site has been ordered recently - GET
/products/tokensPublicToken packages you can buy - POST
/products/suggestAPI keyAsk us to list a new site - GET
/api/news/timelinePublicProduct changelog
curl "https://backend.toplistbot.com/orders/getAllWebsites"Ask for less
The full catalog is about 665 KB, and two fields you will never order with account for half of it: a stored popularity blob and the marketing description. Project down to the ordering fields and drop inactive sites and it becomes about 40 KB.
# The whole catalog is ~665 KB across 396 sites.
# Projected to what you actually order with: ~40 KB.
curl -s "https://backend.toplistbot.com/orders/getAllWebsites" \
| jq '[.[]
| select(.active == 1)
| {id, name, price_per_1000, max_per_hour, accept_rate, subscribeable}]'For an AI agent that is the difference between roughly 170,000 tokens and 10,000 — between the first call working and the first call exhausting the context window. Project before you parse.
Account & sessions
Registration needs a browser: it is gated by a Cloudflare challenge. Sign up once at app.toplistbot.com, then automate everything after it.
- POST
/api/auth/registerPublicCreate an account — browser only, gated by a captcha - POST
/api/auth/loginPublicExchange credentials for a JWT - POST
/api/auth/refreshJWTIssue a fresh JWT from an expiring one - POST
/api/auth/logoutJWTInvalidate the current JWT - GET
/api/auth/user-profileJWTThe signed-in account, with balance and API key - GET
/api/userJWTThe same user object, under a shorter path - GET
/api/api_tokenAPI keyResolve an API key to its owner — use it to validate a key - POST
/api/auth/reset-api-keyJWTRotate your API key; the old one dies immediately - POST
/api/auth/fingerprintJWTRecord a browser fingerprint against the account - POST
/api/auth/ipJWTRecord the account's current IP - POST
/api/auth/forgot-passwordPublicEmail a reset link, valid 60 minutes - POST
/api/auth/reset-passwordPublicSet a new password with the emailed token
Two-factor & sign-in
Two-factor protects the password login. It does not apply to API keys, which is why a key is the better credential for unattended work.
- POST
/api/2fa/enableJWTStart enrolment — returns the secret, QR URL and recovery codes - POST
/api/2fa/verifyJWTConfirm a six-digit code and switch two-factor on - POST
/api/2fa/disableJWTSwitch two-factor off - POST
/api/account/verification/requestJWTEmail a verification link to the signed-in address - GET
/api/account/verification/confirm?token=PublicRender the confirmation page — writes nothing - POST
/api/account/verification/confirmPublicCommit the verification - GET
/api/auth/googlePublicStart Google sign-in - GET
/api/auth/google/callbackPublicGoogle sign-in callback - GET
/api/auth/discordPublicStart Discord sign-in - GET
/api/auth/discord/callbackPublicDiscord sign-in callback
Preferences & alerts
Email and notification switches, your webhook, and the account's alert feed.
- GET
/api/user/email-preferencesJWTMarketing email opt-in state - POST
/api/user/email-preferencesJWTChange it - GET
/api/user/notification-preferencesJWTLive delivery notification preference - POST
/api/user/notification-preferencesJWTChange it — must be a real JSON boolean - GET
/api/user/alerts?limit=20JWTAccount alerts, newest first, paged on ?before - POST
/api/user/alerts/readJWTMark an alert read - POST
/api/user/alerts/dismissJWTDismiss an alert - GET
/api/user/webhooksJWTYour webhook: address, events and delivery health - POST
/api/user/webhooksJWTSave the address, the events and the on/off switch - POST
/api/user/webhooks/testJWTSend one test event now and report what your server answered - GET
/api/user/webhooks/deliveries?limit=25JWTWebhook delivery log, newest first, paged on ?before - GET
/api/email/unsubscribe?token=PublicOne-click unsubscribe from an emailed token
Campaigns
Create campaigns, steer them while they run, and wind them down. This is the core of the platform API.
- GET
/orders/getAllAPI keyYour campaigns, newest first, with their sites joined in - GET
/orders/get/{id}API keyOne campaign - POST
/orders/checkoutAPI keyCreate campaigns and charge the balance - POST
/orders/updateAPI keyEdit a campaign - POST
/orders/pauseAPI keyPause a running campaign - POST
/orders/unpauseAPI keyResume a paused campaign - POST
/orders/archiveAPI keyArchive a campaign - POST
/orders/unarchiveAPI keyRestore an archived campaign - PATCH
/orders/updateLimitAPI keySet or clear the daily action cap
POST /orders/checkout
The body is a top-level JSON array of cart lines, not an object. Every line is one campaign. The whole cart is validated before anything is charged, and the charge and the inserts are one transaction — a checkout either happens completely or not at all.
A fixed-quantity line
The ordinary case: a set number of actions for one URL.
| Parameter | Type | Description |
|---|---|---|
idrequired | integer | Site id from /orders/getAllWebsites. |
amountrequired | integer | Actions to deliver. 0 or more, at most 2,147,483,647. |
ownNamerequired | url | The campaign URL. Stored as the order's `url`. |
custom_max_per_hour | integer | Delivery ceiling, clamped to the site's own maximum. |
extra_col | string | Free-text field carried on the order. |
curl -X POST "https://backend.toplistbot.com/orders/checkout?key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '[
{
"id": 9,
"amount": 1000,
"ownName": "https://arena-top100.com/index.php?a=in&u=yourserver",
"custom_max_per_hour": 60
}
]'200 OK
Successfully purchased with token balanceA subscription line
For sites whose `subscribeable` flag is 1. Priced from the site's subscription_price_1d multiplied by the number of days and the tier discount — Weekly is 0.90, Monthly is 0.80, anything else 1.00. The delivered quantity is derived server-side from the site's own subscription_speed, so nothing you send changes it.
[
{
"type": "subscription",
"website": { "id": 9 },
"subscription_days": 30,
"tier": { "name": "Monthly" },
"url": "https://arena-top100.com/index.php?a=in&u=yourserver"
}
]Responses
200Every line was created and the balance was charged. The body is plain text.400The body was not valid JSON.402Not enough tokens. The message names the amount needed, and nothing was charged.422One or more lines are wrong. Nothing was charged.429The identical cart was submitted within the last 60 seconds. Retry after the stated wait.
A 422 names the offending line: errors are keyed items.[index].[field], so a cart with three bad lines takes one round trip to fix rather than three.
{
"errors": {
"items.2.amount": ["Enter 0 or more votes; a negative amount is not allowed."]
}
}POST /orders/update
`id` is required; send only the fields you are changing. Subscription orders cannot be modified.
| Parameter | Type | Description |
|---|---|---|
idrequired | integer | The campaign to edit. |
amount_to_do | integer | New total actions. Increasing charges the difference, decreasing refunds it, and there is a 30-second cooldown between changes. |
url | url | The campaign URL. |
custom_name | string | Your own label for the campaign. |
custom_max_per_hour | integer | Delivery ceiling. |
username_profile_id | integer | Attach a username profile. |
proxy_profile_id | integer | Attach a proxy profile. |
http_referral | url | Referrer to send with each action. |
extra_col | string | Free-text field. |
curl -X POST "https://backend.toplistbot.com/orders/update?key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":184223,"amount_to_do":2000,"custom_name":"EU launch"}'Daily cap
`type: "delete"` clears the cap. `max_votes_per_day` is still required by the validator in that case — send any integer.
curl -X PATCH "https://backend.toplistbot.com/orders/updateLimit?key=YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"id":184223,"max_votes_per_day":500,"type":"set"}'Logs & analytics
Delivery data, per action and aggregated. These read a separate logs database and are slower than the rest of the API — poll them at minutes, not seconds.
- GET
/orders/logs/{id}API keyPer-action delivery log for a campaign - GET
/orders/graph/{id}API keyTime series for one campaign, ready to chart - GET
/api/orders/graph/summaryJWTOne series across every campaign - GET
/orders/grouped/usernames/{id}API keyDeliveries grouped by username - POST
/orders/averageAPI keyAverage delivery across several campaigns - GET
/api/logs/{id}/filtered-graphJWTFiltered time series
Username & proxy profiles
A username profile is a named list of usernames a campaign uses. A proxy profile is a named country allowlist for the IPs it uses. Attach either to a campaign with username_profile_id or proxy_profile_id on /orders/update.
- GET
/advanced/profile/getAPI keyYour username profiles - GET
/advanced/profile/get/{id}API keyOne username profile - POST
/advanced/profile/createAPI keyCreate a username profile, or overwrite one by id - DELETE
/advanced/profile/delete/{id}API keyDelete a username profile - GET
/advanced/profile/proxy/getAPI keyYour proxy profiles - GET
/advanced/profile/proxy/get/{id}API keyOne proxy profile - POST
/advanced/profile/proxy/createAPI keyCreate a proxy profile, or overwrite one by id - DELETE
/advanced/profile/proxy/delete/{id}API keyDelete a proxy profile
Discord tokens
For toplists that require a Discord sign-in. Adding the same token twice is rejected as a duplicate.
- GET
/api/discord-tokensJWTYour Discord tokens - POST
/api/discord-tokensJWTAdd a token - GET
/api/discord-tokens/statsJWTUsage counts across your tokens - GET
/api/discord-tokens/{id}JWTOne token - PATCH
/api/discord-tokens/{id}JWTChange a token or its enabled flag - DELETE
/api/discord-tokens/{id}JWTRemove a token - PUT
/api/discord-tokens/{id}/toggleJWTFlip a token between enabled and disabled
Billing & payments
Buying tokens always ends on a hosted payment page, so topping up cannot be fully headless. Everything after the top-up can be.
- GET
/invoices/getAPI keyBilling history - GET
/api/subscriptions/subscriptionsJWTActive subscriptions - GET
/products/tokensByUserAPI keyToken packages priced for your account - POST
/company/getAPI keyYour billing address - POST
/company/createAPI keySet it — country, region, city, address, postalCode - GET
/api/stripe/checkout?product_id=JWTA Stripe Checkout URL for a token package - GET
/api/stripe/subscription?plan=JWTA Stripe Checkout URL for a plan - GET
/api/stripe/portalJWTA Stripe billing-portal URL - GET
/api/stripe/documentsJWTStripe invoices and receipts - GET
/coinpayments/checkoutAPI keyA crypto checkout URL
Saved cart
The dashboard's cart, persisted server-side so it survives a device change. You do not need it to place orders — /orders/checkout takes the cart inline.
- GET
/api/cartJWTThe saved cart - PUT
/api/cartJWTReplace it - POST
/api/cartJWTReplace it — same as PUT - DELETE
/api/cartJWTEmpty it
Internal surfaces
These exist for Stripe, the job scheduler and the signup anti-abuse challenge. They are authenticated by shared secrets or signatures and are not part of the integration surface — listed here only so the inventory is complete.
- POST
/api/stripe/webhookStripe payment events — signature authenticated - POST
/api/jobs/tickRuns due background jobs — shared-secret authenticated - POST
/api/internal/vote-eventsVote events from the voter — shared-secret authenticated - POST
/api/pow/challengeSignup proof-of-work challenge - POST
/api/logs/updateEmail activity tracker - POST
/api/order/{email}Places an order on another account — admin allowlist only - GET
/reset-password/{token}The old server-rendered password reset page, kept for emailed links - GET
/PublicHealth check
Response shapes
Two objects carry almost everything you will read: a site, returned by the catalog endpoints, and a campaign, returned by the order endpoints. Each has around forty columns; the tables below are the ones an integration actually needs.
Three fields are JSON strings even though they hold numbers — accept_rate and timeout on a site, custom_max_per_hour on a campaign. Coerce them before doing arithmetic, or your progress figures will be string concatenation.
{
"accept_rate": "70", // string, not number
"timeout": "150000", // string, not number
"custom_max_per_hour": "60" // string, not number
}The site object
Returned by /orders/getAllWebsites and /orders/getWebsiteDetailsByName, and joined into each campaign as `website`.
| Parameter | Type | Description |
|---|---|---|
id | integer | The site id. Pass it as `id` in a checkout line, or as `service` on the SMM endpoint. |
name | string | Display name, and the exact string /orders/getWebsiteDetailsByName matches on. |
price_per_1000 | number | Tokens per 1,000 accepted actions. This is the number the cost formula uses. |
accept_rate | string | Percent of submitted actions that are accepted. Every progress and refund calculation depends on it. |
max_per_hour | integer | The site's own delivery ceiling. Both custom_max_per_hour and the SMM interval are clamped to it. |
active | integer | 1 means orderable. Inactive sites are still returned, so filter them yourself. |
vote_reset_time | integer | Hours before the site accepts another action from the same identity. |
speed_changeable | integer | 1 means the site honours a custom delivery rate. |
referer_must_be_set | integer | 1 means http_referral has to be set on the campaign. |
optional_data_possible | integer | 1 means the site accepts the campaign's optional_data field. |
track_votes | integer | 1 means per-action delivery logs are available for campaigns here. |
subscribeable | integer | 1 means subscription cart lines are accepted. |
subscription_price_1d | number | Tokens per day for a subscription, before the tier discount. |
subscription_speed | integer | Actions per hour a subscription delivers. The quantity is derived from this on the server, never from your request. |
The fields omitted here drive the dashboard's own interface. Read them if you like, but they are not part of the integration contract and may change without notice.
The campaign object
Returned by /orders/getAll and /orders/get/:id. Note what is missing: there is no status field.
| Parameter | Type | Description |
|---|---|---|
id | integer | The campaign id. Every endpoint under Campaigns takes it as `id`. |
vote_website_id | integer | The site this campaign runs on. |
website | object | The full site object, joined in. Present on /orders/getAll, absent on /orders/get/:id. |
url | string | The campaign URL — the `ownName` you sent at checkout. |
custom_name | string | Your own label for the campaign, or null. |
amount_to_do | integer | Accepted actions purchased. Counts accepted actions, not attempts. |
amount_done | integer | Actions submitted so far. A different unit from amount_to_do — see Progress below. |
running | integer | 1 delivering, 0 paused. |
done | integer | 1 means closed: cancelled, refunded, and both amounts zeroed. It is not a completion flag. |
archive | integer | 1 means archived. Archived campaigns are still returned by /orders/getAll. |
custom_max_per_hour | string | Your delivery ceiling for this campaign, as a string. |
max_votes_per_day | integer | Daily action cap, or null when uncapped. |
is_subscription | integer | 1 means a subscription. Subscriptions cannot be edited after purchase. |
paused_unpaused | datetime | When the campaign was last paused, resumed or resized. This is what starts the 30-second edit cooldown. |
The fields omitted here drive the dashboard's own interface. Read them if you like, but they are not part of the integration contract and may change without notice.
Is it running? Is it finished?
A campaign has no status field, so you derive its state from four columns. Evaluate these in order and take the first match.
| Test | Means |
|---|---|
1done === 1 | Cancelled. The unspent remainder was refunded, both amounts were zeroed and archive was set. |
2running === 0 | Paused by you. Resume it with /orders/unpause. |
3remaining_accepted === 0 | Everything purchased has been delivered. |
4running === 1 | Running normally. |
5archive === 1 | Hidden in the dashboard, but still returned by /orders/getAll. Filter it out if you want to match what the dashboard shows. |
The order matters. Cancelling sets done and archive together, so testing archive first would report a cancelled campaign as merely archived, and testing remaining before running would report a paused campaign as delivering.
Progress and refunds
amount_to_do counts accepted actions; amount_done counts submissions. Only accept_rate percent of submissions are accepted, so the two are in different units and subtracting one from the other directly is wrong.
This is the most common integration mistake, and it fails silently — the numbers stay plausible and the progress bar is simply wrong. At a 70 percent accept rate a finished campaign reads as 70 percent done; at 50 percent, a half-delivered campaign reads as untouched. Convert submissions to accepted actions first, every time.
// accept_rate arrives as a STRING, and it is per-site, not global.
const rate = Number(order.website.accept_rate)
// amount_done counts SUBMISSIONS; amount_to_do counts ACCEPTED actions.
// Convert before comparing them.
const delivered = order.amount_done * rate / 100
const remaining = Math.max(order.amount_to_do - delivered, 0)
const percent = 100 * delivered / order.amount_to_do
// What cancelling right now would put back on your balance:
const refund = (remaining / 1000) * order.website.price_per_1000The same arithmetic prices a cancellation: the refund is the unspent remainder at the site's list price, so you can tell what stopping a campaign is worth before you commit to it.
A campaign end to end
Six calls, one API key, no browser and no login. The same thing through the SMM endpoint is three calls — services, add, status — and does not touch the platform API at all.
KEY=YOUR_API_KEY
BASE=https://backend.toplistbot.com
# 1. What can I order, and what does it cost?
curl -s "$BASE/orders/getAllWebsites" \
| jq '.[] | {id, name, price_per_1000, max_per_hour}'
# 2. What can I afford?
curl -s -X POST "$BASE/api/v2" -d "key=$KEY" -d "action=balance"
# 3. Launch it. cost = price_per_1000 * amount / 1000
curl -s -X POST "$BASE/orders/checkout?key=$KEY" \
-H "Content-Type: application/json" \
-d '[{"id":9,"amount":1000,
"ownName":"https://arena-top100.com/index.php?a=in&u=me",
"custom_max_per_hour":60}]'
# 4. Find the campaign that was just created.
curl -s "$BASE/orders/getAll?key=$KEY" | jq '.[0] | {id, url, amount_to_do, amount_done}'
# 5. Watch it. Poll every few minutes.
curl -s "$BASE/orders/graph/184223?key=$KEY"
# 6. Slow it down, or stop it.
curl -s -X PATCH "$BASE/orders/updateLimit?key=$KEY" \
-H "Content-Type: application/json" -d '{"id":184223,"max_votes_per_day":200,"type":"set"}'
curl -s -X POST "$BASE/orders/pause?key=$KEY" \
-H "Content-Type: application/json" -d '{"id":184223}'Webhooks
Polling tells you what changed since you last asked. A webhook tells you as it happens: when one of your votes starts, goes through or fails, Toplistbot sends a POST request to an address you choose. Use it to reward players the moment their vote lands, to reconcile against your own logs, or to notice straight away when a campaign stops delivering.
One address per account
A webhook belongs to your account, not to a single campaign. Every campaign you run reports to the same address, and each event names the campaign it came from.
Setting one up
Four steps in the dashboard, and nothing to install.
Open the Webhook card
In the dashboard, go to Account and open the Notifications tab.
Paste your address
The URL of the endpoint on your server that should receive events. It must start with https:// and be reachable from the internet.
Choose your events
Votes that went through and votes that failed are on by default. Vote started is off; switch it on only if you act on it.
Save, then send a test
The address is checked when you save, and you are told straight away if it cannot be found or points at a private network. Then press Send test: a test event goes out at once, and the card shows what your server answered.
Over the API
The same settings are available over the platform API with a JWT. An API key cannot change them: the key is for running campaigns, and it must not be able to redirect your account's events somewhere else.
| Parameter | Type | Description |
|---|---|---|
urlrequired | url | Your HTTPS endpoint, up to 500 characters. Required on every save. |
enabled | boolean | The on/off switch. Defaults to true. Must be a real JSON boolean. |
events | string[] | Which events to send. Defaults to what is already saved, or to vote.succeeded and vote.failed for a new webhook. |
curl -X POST https://behind.toplistbot.com/api/user/webhooks \
-H "Authorization: Bearer YOUR_JWT" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/toplistbot/3f9c1a7e5b2d",
"enabled": true,
"events": ["vote.succeeded", "vote.failed"]
}'{
"available": true,
"webhook": {
"url": "https://example.com/hooks/toplistbot/3f9c1a7e5b2d",
"enabled": true,
"events": ["vote.succeeded", "vote.failed"],
"failing": false,
"retry_at": null,
"last_ok_at": null,
"last_error": null,
"last_error_at": null,
"created_at": "2026-10-06T09:12:44.000000Z",
"updated_at": "2026-10-06T09:12:44.000000Z"
},
"events": ["vote.started", "vote.succeeded", "vote.failed"],
"default_events": ["vote.succeeded", "vote.failed"]
}A test is sent exactly once and never retried. The response tells you whether it arrived and what your server answered.
curl -X POST https://behind.toplistbot.com/api/user/webhooks/test \
-H "Authorization: Bearer YOUR_JWT"{
"delivered": false,
"http_status": 500,
"error": "Your server had an error (HTTP 500).",
"delivery": { "id": 1874, "event": "webhook.test", "status": "failed", "...": "..." }
}Events
Every request carries exactly one event.
| Event | When it is sent |
|---|---|
vote.started | A vote for one of your campaigns has started. Off by default: it doubles the number of requests, and most integrations only act on the result. |
vote.succeeded | The vote went through. |
vote.failed | The vote did not go through. No reason is given; the campaign simply carries on with its next vote. |
webhook.test | Only sent by the Send test button, and always sent, whatever events you chose. |
Payload and headers
Each event is a POST request with a small JSON body.
POST /hooks/toplistbot/3f9c1a7e5b2d HTTP/1.1
Content-Type: application/json
User-Agent: Toplistbot-Webhooks/1.0 (+https://toplistbot.com)
X-Toplistbot-Event: vote.succeeded
X-Toplistbot-Delivery: 1873
{
"id": 1873,
"event": "vote.succeeded",
"created_at": "2026-10-06T09:12:44.123Z",
"order_id": 184223,
"vote_job_id": "7f3c2a9e-1b4d-4c8e-9a2f-5d6e7f8a9b0c",
"site": "https://arena-top100.com/index.php?a=in&u=yourserver",
"username": "PlayerOne"
}| Parameter | Type | Description |
|---|---|---|
id | integer | Unique per event. A retry carries the same id, so use it to skip duplicates. |
event | string | Which event this is. |
created_at | datetime | When the event was recorded, in UTC. |
order_id | integer | The campaign the vote belongs to. The same id the campaign endpoints use. |
vote_job_id | string | Our reference for this vote. Quote it when you ask support about one. Can be null. |
site | string | The vote address of the campaign, as you entered it. |
username | string | The username the vote was cast with. Match on this, not on IP address, when you compare events with your own logs. Can be null. |
The key facts are repeated in the headers, so you can route a request without parsing its body.
| Header | Description |
|---|---|
Content-Type | Always application/json. |
X-Toplistbot-Event | The event name, the same as event in the body. |
X-Toplistbot-Delivery | The event id, the same as id in the body. |
User-Agent | Identifies the sender. Do not rely on it for security. |
Receiving events and retries
Answer with any 2xx status within 5 seconds. Do the slow work, such as database writes or rewarding a player, after you have answered or in a queue of your own.
2xxDelivered. Nothing more happens.3xxRedirects are not followed. The event counts as failed and is not retried, so save the final address instead.4xxRefused. The event is marked failed and not retried, because sending it again would get the same answer. 408, 425 and 429 are the exception: those are retried.5xxRetried, and so are timeouts, refused connections and invalid certificates.
When your server fails, we stop sending to it straight away instead of knocking on a closed door. New events are queued, and we try again after 1 minute, then 2, 4, 8 and so on, up to an hour apart. Retries run every 5 minutes, so a wait is rounded up to the next run. As soon as your server answers again, the queue is sent oldest first. Pressing Send test after you have fixed your server releases the queue at once.
An event that still could not be delivered after 24 hours is marked failed. Switching the webhook off discards everything that is still queued.
Expect duplicates, not order
Delivery is at least once. In rare cases, such as a timeout after your server had already processed the event, the same event arrives twice. Store the ids you have handled and skip any you have seen before. Events can also arrive out of order, especially after a retry: sort on created_at if order matters to you.
A minimal receiver: answer first, skip duplicates, then do the work.
import express from 'express'
const app = express()
const seen = new Set() // use your database in production
app.post('/hooks/toplistbot/3f9c1a7e5b2d', express.json(), (req, res) => {
// 1. Answer straight away. Anything slower than 5 seconds is a failure.
res.sendStatus(204)
// 2. Skip duplicates. A retry carries the same id.
const event = req.body
if (seen.has(event.id)) return
seen.add(event.id)
// 3. Now do the work.
if (event.event === 'vote.succeeded') {
rewardPlayer(event.username, event.order_id)
}
})
app.listen(3000)Delivery log
Every event we send is logged with the status your server answered and, for a failure, the reason. The dashboard shows the log under the Webhook card. Over the API it comes newest first, 25 per page by default and at most 100. Delivered events are kept for 7 days and failed ones for 30.
curl "https://behind.toplistbot.com/api/user/webhooks/deliveries?limit=25" \
-H "Authorization: Bearer YOUR_JWT"
# next page: pass the id of the last delivery you received
curl "https://behind.toplistbot.com/api/user/webhooks/deliveries?limit=25&before=1850" \
-H "Authorization: Bearer YOUR_JWT"{
"deliveries": [
{
"id": 1873,
"event": "vote.succeeded",
"order_id": 184223,
"status": "delivered",
"attempts": 1,
"http_status": 204,
"error": null,
"created_at": "2026-10-06T09:12:44.000000Z",
"delivered_at": "2026-10-06T09:12:44.000000Z",
"payload": { "id": 1873, "event": "vote.succeeded", "...": "..." }
}
],
"has_more": true,
"available": true
}Security
- HTTPS only. Events are never sent over an unencrypted connection.
- The address must be public. Addresses on private or local networks are refused when you save, and again every time we send, including a domain that is later pointed at one.
- No user name or password in the address. Use a secret path instead.
- Saving is limited to 20 times a minute and Send test to 5 times a minute.
Requests are not signed yet
Anyone who knows your webhook address can send it a request that looks like ours. Until signing is available, put a long random token in the path of the address and keep the address private. Before you hand out anything of value because of an event, confirm it through the API: the campaign endpoints show the same delivery from our side.
Errors
Errors come back with a matching HTTP status. Validation failures return an `errors` object keyed by field name.
400The request could not be read at all — malformed JSON, or an action the SMM endpoint does not know.401Missing or invalid credentials.402Not enough tokens. Nothing was charged.403Authenticated, but not allowed to do this.404No such record — including one that belongs to somebody else.409Conflicts with the account's current state, such as enabling two-factor twice.422The request was understood but failed validation.429Rate limited. The message states how long to wait.500Our fault. Nothing was charged.503A dependency is unavailable. Retry later.
{
"errors": {
"quantity": ["The quantity must be at least 1."]
}
}When the request body was an array, the error keys carry the index of the line that failed.
A few endpoints answer with plain text rather than JSON — /orders/checkout, /orders/pause and /orders/updateLimit among them. Branch on the status code, not on the body shape.
Rate limits
A 429 always states how long to wait. Honour it rather than retrying blind.
- The identical checkout cart is accepted at most once per 60 seconds.
- A campaign's action total can be changed once per 30 seconds.
- Login attempts are throttled per address and per IP.
- Password reset: 3 per address and 10 per IP per 15 minutes.
- `status` and `cancel` take at most 100 order ids per call.
- Poll campaign progress at minutes, not seconds. Delivery is measured in actions per hour.
Limits & notes
- Order quantity must be between 1 and 50000 actions.
- Interval defaults to 15 per hour and is capped at 4000. Requesting more than a site's own maximum is rejected with a 400 naming the limit.
- The `refill` and `refill_status` actions are not implemented — create a new order instead.
- The `status` field is always the literal "Completed" and is not a progress signal. Use `remains == 0` to decide an order has finished, and `start_count` for what has been delivered.
- Subscriptions cannot be edited after purchase — pause or cancel instead.
- Listing sites set their own rules and change them over time. You are responsible for making sure your use complies with the terms of any site you promote on. We do not promise any particular ranking or placement.
