Documentation

API documentation

Everything the dashboard does is available over HTTP. Point an SMM panel at one endpoint, or drive the whole platform — catalog, campaigns, logs, invoices — from a script or an AI agent.

On this page

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.

Base URL

Base URL
https://backend.toplistbot.com/api
https://backend.toplistbot.com

Reference 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.

  1. 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.

  2. 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.

  3. 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.

    cURL
    curl -X POST https://backend.toplistbot.com/api/v2 -d "key=YOUR_API_KEY" -d "action=services"
  4. 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.

    cURL
    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"
  5. 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.

    cURL
    curl -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.txt

Starting 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.

Prompt
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.

PathCredentialUse it for
/api/orders/getAllJWTAnything a human signs into
/orders/getAllAPI keyScripts, 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.

cURL
# 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"
Health check
curl https://backend.toplistbot.com/api/v2?key=YOUR_API_KEY

JWT

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`.

Login
curl -X POST https://backend.toplistbot.com/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"..."}'
Response
{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
  "token_type": "bearer",
  "expires_in": 3600,
  "user": { "id": 4211, "email": "[email protected]", "tokens": 528.41, "...": "..." }
}
Authenticated request
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.

JavaScript
// 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 formula
cost_in_tokens = (rate * quantity) / 1000

A 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`.

POSThttps://backend.toplistbot.com/api/v2
ActionParameters
services—
addservice, link, quantity, interval?
statusorders
balance—
cancelorders

`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
curl -X POST https://backend.toplistbot.com/api/v2 \
  -d "key=YOUR_API_KEY" \
  -d "action=services"
Response
[
  {
    "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.

ParameterTypeDescription
keyrequiredstringYour API key.
actionrequiredstringMust be `add`.
servicerequiredintegerService id from the services action.
linkrequiredurlThe URL the campaign runs against. Must be a valid URL.
quantityrequiredintegerNumber of actions to run, between 1 and 50000.
intervalintegerActions per hour. Defaults to 15, capped at 4000, and cannot exceed the site's own maximum.
cURL
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"
Response
{
  "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
curl -X POST https://backend.toplistbot.com/api/v2 \
  -d "key=YOUR_API_KEY" \
  -d "action=status" \
  -d "orders=184223"
Response — single order
{
  "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.

Response — multiple orders
{
  "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
curl -X POST https://backend.toplistbot.com/api/v2 \
  -d "key=YOUR_API_KEY" \
  -d "action=balance"
Response
{
  "balance": 528.41,
  "currency": "USD"
}

action=cancel

Stops an order and refunds the unspent remainder to your balance. Completed orders cannot be cancelled.

cURL
curl -X POST https://backend.toplistbot.com/api/v2 \
  -d "key=YOUR_API_KEY" \
  -d "action=cancel" \
  -d "orders=184223,184224"
Response
[
  { "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
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.

cURL + jq
# 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.

ParameterTypeDescription
idrequiredintegerSite id from /orders/getAllWebsites.
amountrequiredintegerActions to deliver. 0 or more, at most 2,147,483,647.
ownNamerequiredurlThe campaign URL. Stored as the order's `url`.
custom_max_per_hourintegerDelivery ceiling, clamped to the site's own maximum.
extra_colstringFree-text field carried on the order.
cURL
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
    }
  ]'
Response
200 OK
Successfully purchased with token balance

A 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.

Body
[
  {
    "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.

422 body
{
  "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.

ParameterTypeDescription
idrequiredintegerThe campaign to edit.
amount_to_dointegerNew total actions. Increasing charges the difference, decreasing refunds it, and there is a 30-second cooldown between changes.
urlurlThe campaign URL.
custom_namestringYour own label for the campaign.
custom_max_per_hourintegerDelivery ceiling.
username_profile_idintegerAttach a username profile.
proxy_profile_idintegerAttach a proxy profile.
http_referralurlReferrer to send with each action.
extra_colstringFree-text field.
cURL
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
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.

Types to watch
{
  "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`.

ParameterTypeDescription
idintegerThe site id. Pass it as `id` in a checkout line, or as `service` on the SMM endpoint.
namestringDisplay name, and the exact string /orders/getWebsiteDetailsByName matches on.
price_per_1000numberTokens per 1,000 accepted actions. This is the number the cost formula uses.
accept_ratestringPercent of submitted actions that are accepted. Every progress and refund calculation depends on it.
max_per_hourintegerThe site's own delivery ceiling. Both custom_max_per_hour and the SMM interval are clamped to it.
activeinteger1 means orderable. Inactive sites are still returned, so filter them yourself.
vote_reset_timeintegerHours before the site accepts another action from the same identity.
speed_changeableinteger1 means the site honours a custom delivery rate.
referer_must_be_setinteger1 means http_referral has to be set on the campaign.
optional_data_possibleinteger1 means the site accepts the campaign's optional_data field.
track_votesinteger1 means per-action delivery logs are available for campaigns here.
subscribeableinteger1 means subscription cart lines are accepted.
subscription_price_1dnumberTokens per day for a subscription, before the tier discount.
subscription_speedintegerActions 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.

ParameterTypeDescription
idintegerThe campaign id. Every endpoint under Campaigns takes it as `id`.
vote_website_idintegerThe site this campaign runs on.
websiteobjectThe full site object, joined in. Present on /orders/getAll, absent on /orders/get/:id.
urlstringThe campaign URL — the `ownName` you sent at checkout.
custom_namestringYour own label for the campaign, or null.
amount_to_dointegerAccepted actions purchased. Counts accepted actions, not attempts.
amount_doneintegerActions submitted so far. A different unit from amount_to_do — see Progress below.
runninginteger1 delivering, 0 paused.
doneinteger1 means closed: cancelled, refunded, and both amounts zeroed. It is not a completion flag.
archiveinteger1 means archived. Archived campaigns are still returned by /orders/getAll.
custom_max_per_hourstringYour delivery ceiling for this campaign, as a string.
max_votes_per_dayintegerDaily action cap, or null when uncapped.
is_subscriptioninteger1 means a subscription. Subscriptions cannot be edited after purchase.
paused_unpauseddatetimeWhen 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.

TestMeans
1done === 1Cancelled. The unspent remainder was refunded, both amounts were zeroed and archive was set.
2running === 0Paused by you. Resume it with /orders/unpause.
3remaining_accepted === 0Everything purchased has been delivered.
4running === 1Running normally.
5archive === 1Hidden 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.

JavaScript
// 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_1000

The 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.

bash
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.

  1. Open the Webhook card

    In the dashboard, go to Account and open the Notifications tab.

  2. 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.

  3. 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.

  4. 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.

ParameterTypeDescription
urlrequiredurlYour HTTPS endpoint, up to 500 characters. Required on every save.
enabledbooleanThe on/off switch. Defaults to true. Must be a real JSON boolean.
eventsstring[]Which events to send. Defaults to what is already saved, or to vote.succeeded and vote.failed for a new webhook.
cURL
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"]
  }'
Response
{
  "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
curl -X POST https://behind.toplistbot.com/api/user/webhooks/test \
  -H "Authorization: Bearer YOUR_JWT"
Response — the test did not arrive
{
  "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.

EventWhen it is sent
vote.startedA 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.succeededThe vote went through.
vote.failedThe vote did not go through. No reason is given; the campaign simply carries on with its next vote.
webhook.testOnly 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.

Request
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"
}
ParameterTypeDescription
idintegerUnique per event. A retry carries the same id, so use it to skip duplicates.
eventstringWhich event this is.
created_atdatetimeWhen the event was recorded, in UTC.
order_idintegerThe campaign the vote belongs to. The same id the campaign endpoints use.
vote_job_idstringOur reference for this vote. Quote it when you ask support about one. Can be null.
sitestringThe vote address of the campaign, as you entered it.
usernamestringThe 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.

HeaderDescription
Content-TypeAlways application/json.
X-Toplistbot-EventThe event name, the same as event in the body.
X-Toplistbot-DeliveryThe event id, the same as id in the body.
User-AgentIdentifies 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.

JavaScript
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
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"
Response
{
  "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.
Validation error
{
  "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.
Free to start

Start Promoting Now!

Verify your email and get 1 free token to try out our service. No strings attached.

No credit card
Cancel anytime
Email and Discord support