Skip to content

← Add your server

Developer guide

Vote callback reference

When a player votes for your server, we tell your server by opening a web address you choose, with the player's name on the end. Your script gives the reward and says “OK”. That is the whole thing; this page covers the details.

We send1 GET requestno body, no JSON
Success meansHTTP 200the body is ignored
Answer within3 secondsor it counts as failed
If it fails5 retriesafter 30 s, 2 min, 10 min, then about 1 h and 6 h
Player opens your vote link…/server/293/vote/Zezima — the last part is the player's name.
They solve the captchaand press Vote Now.
We call your callback URLwith the name attached: one GET request.
Your script rewards the playerand answers with status 200.

The request we send

The part after /vote/ in the vote link is called the incentive. It is normally the player's in-game name, but it can be anything your server understands, such as an account id or a one-time token. We add it to the end of the callback URL you saved. How we add it depends only on how your saved URL ends:

Ends with =the most flexible
You savehttps://play.example/vote?user=
We callhttps://play.example/vote?user=Zezima
Ends with .phpclassic vote scripts
You savehttps://play.example/callback.php
We callhttps://play.example/callback.php?postback=Zezima
Anything elsename in the path
You savehttps://play.example/api/vote
We callhttps://play.example/api/vote/Zezima
With the first shape you can put your own parameters in front, for example a secret key: https://play.example/vote?key=YOUR_SECRET&user=

On the wire it looks like this (plus the usual connection headers):

GET /vote?user=Zezima HTTP/1.1
Host: play.example
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.100 Safari/537.36
Accept: text/html,application/xhtml+xml,…
Accept-Language: en-US,en;q=0.9
X-RuneList-Vote-Id: 48211907
X-RuneList-Attempt: 1
X-RuneList-Timestamp: 1790000000
X-RuneList-Voter-Ip: 203.0.113.42
X-RuneList-Voter-Country: GB
  • Always GET. Nothing is sent in a request body. A signature header is added only if you turn on signed callbacks.
  • Extra headers carry what the URL cannot. X-RuneList-Vote-Id is the same on every retry of one vote, X-RuneList-Attempt counts the tries, and X-RuneList-Timestamp is when we sent it (Unix seconds). You can ignore them; if you store the vote id you can skip a vote you already rewarded.
  • X-RuneList-Voter-Ip is the address the vote came from (IPv4 or IPv6) and X-RuneList-Voter-Country its two-letter country code, empty when we do not know it. Use them to spot the same person voting through many proxies, to give a reward only once per address, or to keep a region out of a promotion. They are in headers, not in the URL, so a script written for the old site keeps working unchanged. Read them with $_SERVER['HTTP_X_RUNELIST_VOTER_IP'] in PHP, req.headers['x-runelist-voter-ip'] in Node. Treat the address as personal data: keep it only as long as your reward rule needs it.
  • Both https:// and http:// work, and up to 5 redirects are followed. Use https when you can. The address has to be public: we do not call localhost, private network addresses or machine names without a domain.
  • We identify as a normal web browser, because many hosts block unknown clients. Do not use the User-Agent or our IP address to recognise us; both can change.
  • The incentive is added exactly as it appears in the vote link, up to 255 characters. We do not clean or escape it: a space reaches you as a space, and characters such as &, # or ? in a name would change the URL. Always check the value before you use it.

What your script should answer

  • Answer with status 200 once the reward is stored. Any 2xx status counts as delivered; redirects that lead nowhere, 4xx and 5xx count as failed.
  • The response body does not matter. Keep it short (ok is perfect): we save the first 500 characters in your callback log so you can debug.
  • You have 3 seconds. A slower answer, a connection error or any other status counts as failed.
  • After a failure we try again 5 more times: after 30 s, then 2 min, then 10 min, and then about 1 hour and 6 hours after the vote, so a restart or a short outage does not lose the reward. After the last one we stop.
  • If nothing gets through for an hour while players keep voting, we email the server's owner once. While your address keeps failing we check it every 10 minutes instead of on every vote, and deliver the waiting rewards as soon as it answers.
You can receive the same vote twice. If your script stores the reward but answers too slowly, we think it failed and call again. Answer fast, and make rewards safe to repeat, for example at most one reward per player every 12 hours.

Player names with spaces

A web address cannot contain a real space, so a name like Iron Man has to be written in a way that survives the trip. You have two good options. Pick one and use it everywhere.

OptionVote link you give the playerWhat we callWhat your script reads
%20 (the standard way)…/vote/Iron%20Man…?user=Iron%20ManIron Man — PHP, Express, Flask, ASP.NET, Go and Sinatra decode it for you
Underscore (the RSPS habit)…/vote/Iron_Man…?user=Iron_ManIron_Man — turn _ into a space yourself before you look the player up
  • Build the link with your language's URL encoder instead of by hand: rawurlencode() in PHP, encodeURIComponent() in JavaScript, urllib.parse.quote() in Python, Uri.EscapeDataString() in C#, url.PathEscape() in Go.
  • Java: URLEncoder.encode(name, UTF_8) writes a space as +, and in this part of a link a plus stays a plus, so the player would be called Iron+Man. Add .replace("+", "%20").
  • If you read the raw query string yourself (as the Java example does), decode it; the example's URLDecoder.decode line does exactly that.
  • A player who types a plain space into the address bar is fine too: the browser turns it into %20 on its own.
  • Names are passed on exactly as written, including upper and lower case. If your game treats iron man, Iron Man and Iron_Man as the same player, normalise the name in your script before you store the reward. The name check in the examples already allows spaces, _ and -.

When we call you, and when we don't

We call your URL
  • For every vote that passes the captcha and has a name in the link.
  • Also when the vote does not count towards your rank because the same device voted before or a VPN was detected. The player did the work, so they still get the reward.
We do not call
  • When the same IP address already voted for you in the last 12 hours.
  • When the vote link has no name (…/vote/ with nothing after it).
  • When one connection sends far too many votes in a few minutes.

Code examples

Every example does the same four things: check the secret key, check that the name looks like a real player name, store the reward, answer 200. Pick your language, change the name rule and the “your own code” line to fit your game.

Callback URL to save: https://play.example/callback.php
<?php
// callback.php
$player = $_GET['postback'] ?? '';

// 1. Only accept names your game could really have.
if (!preg_match('/^[A-Za-z0-9 _-]{1,12}$/', $player)) {
    http_response_code(400);
    exit('bad name');
}

// 2. Store the reward (your own table; the player claims it in game).
$db = new PDO('mysql:host=localhost;dbname=game;charset=utf8mb4', 'user', 'password');
$stmt = $db->prepare(
    'INSERT INTO vote_rewards (player, site, created_at) VALUES (?, ?, NOW())'
);
$stmt->execute([$player, 'runelist']);

// 3. Tell us it worked. Anything other than 200 is retried.
http_response_code(200);
echo 'ok';

Ends in .php, so the name arrives as ?postback=. To use a secret key with PHP, save https://play.example/callback.php?key=YOUR_SECRET&user= instead and read $_GET['user'].

Keep it safe

  • Use a secret key in the URL (see the examples). Without one, anyone who finds the address can call it and hand out rewards.
  • Validate the name. Accept only what your game allows and use parameterised database queries, never string-built SQL.
  • Give the reward in game, not in the script. Store a row and let the player claim it on login or with a command, so a slow database never blocks the answer.
  • Limit rewards per player to one every 12 hours. It makes retries harmless and stops anyone replaying a captured URL.

Signed callbacks (optional)

A secret in the URL keeps strangers out only as long as the URL stays secret, and it ends up in server logs. Signing is stronger: turn it on under Signed callbacks on your server's edit page and every callback carries an X-RuneList-Signature header. It is the lowercase hex of HMAC-SHA256(secret, "timestamp.voteId.player"), where timestamp is the X-RuneList-Timestamp header, voteId is X-RuneList-Vote-Id (empty for the Test button) and player is the name at the end of the URL, decoded. Servers that do not turn it on see no change.

  • Compare in constant time, and refuse a timestamp more than 300 seconds away from your clock. Each retry is signed afresh with its own timestamp.
  • A captured request can still be replayed inside that window; storing the vote ids you have rewarded makes that useless as well.
  • The secret is shown once, when you make it. Rotating it replaces the old one at once.
Callback URL to save: https://play.example/vote.php?user=
<?php
$secret = 'rlcb_your-secret-from-the-edit-page';
$user   = $_GET['user'] ?? '';
$ts     = $_SERVER['HTTP_X_RUNELIST_TIMESTAMP'] ?? '';
$voteId = $_SERVER['HTTP_X_RUNELIST_VOTE_ID'] ?? '';   // empty for the Test button
$sig    = $_SERVER['HTTP_X_RUNELIST_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', "$ts.$voteId.$user", $secret);
if (!hash_equals($expected, $sig) || abs(time() - (int)$ts) > 300) {
    http_response_code(403);
    exit('bad signature');
}
// Optional: skip a vote id you already rewarded (a retry sends the same id).

// ... your own code: store the reward for $user ...
echo 'OK';

Test your check against this example, which uses no real secret:

secret    rlcb_example-secret-do-not-use
timestamp 1790000000
vote id   123456
player    Zezima
signed    1790000000.123456.Zezima
signature edc8c70d79476a9a2a30c555a840b81b82097ef11ee72124dc5bcb99717da9be

Allow the callback through Cloudflare

If your website is behind Cloudflare, its bot protection can mistake our request for an attack and answer 403 or a challenge page before your script ever runs. The fix is a rule that says “leave this one address alone”. It takes two minutes. In these steps, replace /callback.php with the path of your own script.

  1. In the Cloudflare dashboard open your domain, then Security → WAF → Custom rules (in the newer dashboard: Security → Security rules) and choose Create rule. Name it RuneList vote callback.
  2. Under When incoming requests match set Field URI Path, Operator equals, Value /callback.php. If you use a secret key, make the rule stricter with Edit expression:
    (http.request.uri.path eq "/callback.php" and http.request.uri.query contains "key=YOUR_SECRET")
  3. Under Then take action choose Skip and tick:
    • All remaining custom rules
    • All rate limiting rules
    • All managed rules
    • All Super Bot Fight Mode rules
  4. Set Place at to First so it runs before your other rules, then Deploy.
  5. Using I'm Under Attack mode, a high Security Level or Browser Integrity Check? Those are not covered by the Skip rule. Go to Rules → Configuration Rules → Create rule, match the same URI Path, and for that path turn I'm Under Attack off (or set Security Level to its lowest) and switch Browser Integrity Check off.
  6. Press Test callback on your listing. If it still fails, open Security → Events (Analytics → Events in the newer dashboard), filter by your path, and the entry tells you which Cloudflare feature blocked the request.
Free plan with Bot Fight Mode on? Cloudflare does not let any rule skip Bot Fight Mode; only Super Bot Fight Mode (Pro and up) can be skipped. On the free plan either switch Bot Fight Mode off under Security → Bots, or put the callback on its own subdomain (for example vote.yourserver.com) and set that DNS record to DNS only (grey cloud) so it does not pass through Cloudflare at all.

Do not allow-list us by IP address: our servers' addresses can change. A rule on the path plus your secret key keeps working.

Test it

On your listing's edit page, the Test callback button sends a real request with the name RuneListTest and shows the exact URL we called, the status code, how long it took and the start of your response. The test waits up to 8 seconds, so a script that passes the test slowly can still be too slow for real votes.

Your server's dashboard lists the latest real callbacks with the same details, including every failed attempt. A test vote of your own looks like this:

https://runelist.io/toplist/server/{your id}/vote/YourName

Report your player count (optional)

Your listing shows “≈ N players online”, an estimate worked out from votes. If your game sends the real number, the listing shows that instead, labelled reported by the server. Send it every minute or two with the same API key as RuneTrack's join reports (make it on your RuneTrack page in your account):

  • POST /api/track/players/ with X-Api-Key and a JSON body {"players": 1240}: a whole number from 0 to 100,000. At most 30 calls a minute.
  • A report counts for 15 minutes. After that, or if you stop sending, the listing goes back to the estimate by itself.
  • A number far above what your votes suggest is not shown and staff are told; the next realistic report clears it. The answer says "shown": false when that happens.
Callback URL to save: https://runelist.io/api/track/players/
curl -X POST https://runelist.io/api/track/players/ \
  -H "X-Api-Key: rt_your-key-from-the-runetrack-page" \
  -H "Content-Type: application/json" \
  -d '{"players": 1240}'
# -> {"ok":true,"shown":true}

RuneVote: votes from other toplists

RuneVote by RuneList gives your server one vote page, runelist.io/vote/yourserver (or on your own host, such as vote.yourserver.com), that lists every toplist you are on. Players type their name once and vote on each site; every toplist's callback lands at RuneList, and each confirmed vote reaches your game as the same request described above. Nothing in your script has to change: the name is where it always is.

Two more headers say where the vote came from:

X-RuneVote-Site: topg
X-RuneVote-Vote-Id: 4471
  • X-RuneVote-Site is the toplist: rulocus, rsps-list, top100arena, topg, rune-server, rsps-org, rsps100, moparscape, arena-top100 or moparscape-org. A vote cast on RuneList itself arrives without these headers, exactly as today.
  • X-RuneVote-Vote-Id is RuneVote's id for that vote, the same on every retry. X-RuneList-Vote-Id carries the same value for these votes.
  • You get one request per toplist per vote, so a player who votes on six sites earns six rewards. If you would rather give one reward for the set, count them by X-RuneVote-Site in your script for now; a points mode with one claim per set is planned.
  • Only votes the toplist confirmed as counted are forwarded, and never twice for the same name on the same toplist inside its cooldown. Every toplist on the page confirms votes by callback; RuneVote verifies each one the way that toplist allows (a shared secret, its source address, or a one-time token in the vote link that it has to echo back).

Switch it on and paste each toplist's callback address from My servers → RuneVote. Each card has a "Send a test vote" button that runs the whole chain to your script with the name RuneVoteTest.

When it does not work

  • Open the callback URL with a test name in your own browser. Do you get your “ok”, without a login page or an error?
  • Is the address reachable from the internet? localhost, a home network address or a port your firewall blocks cannot be reached by us.
  • Cloudflare “Under Attack” mode, bot protection or a CAPTCHA in front of the script answers us with 403 or 503. Exclude the callback path from them: see the Cloudflare steps.
  • Does the saved URL end the way you meant? A missing = turns ?user=Zezima into ?user/Zezima.
  • An expired or self-signed https certificate makes the request fail.
  • A redirect to a login or error page is not a success, even if that page loads.

Still stuck? Ask in our Discord and include the line from your callback log.