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.
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:
https://play.example/vote?user=https://play.example/vote?user=Zezimahttps://play.example/callback.phphttps://play.example/callback.php?postback=Zezimahttps://play.example/api/votehttps://play.example/api/vote/Zezimahttps://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-Idis the same on every retry of one vote,X-RuneList-Attemptcounts the tries, andX-RuneList-Timestampis 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-Ipis the address the vote came from (IPv4 or IPv6) andX-RuneList-Voter-Countryits 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://andhttp://work, and up to 5 redirects are followed. Use https when you can. The address has to be public: we do not calllocalhost, 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
2xxstatus counts as delivered; redirects that lead nowhere,4xxand5xxcount as failed. - The response body does not matter. Keep it short (
okis 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.
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.
| Option | Vote link you give the player | What we call | What your script reads |
|---|---|---|---|
| %20 (the standard way) | …/vote/Iron%20Man | …?user=Iron%20Man | Iron Man — PHP, Express, Flask, ASP.NET, Go and Sinatra decode it for you |
| Underscore (the RSPS habit) | …/vote/Iron_Man | …?user=Iron_Man | Iron_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 calledIron+Man. Add.replace("+", "%20"). - If you read the raw query string yourself (as the Java example does), decode it; the example's
URLDecoder.decodeline does exactly that. - A player who types a plain space into the address bar is fine too: the browser turns it into
%20on its own. - Names are passed on exactly as written, including upper and lower case. If your game treats
iron man,Iron ManandIron_Manas 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
- 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.
- 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.
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'].
https://play.example/api/vote?key=YOUR_SECRET&user=import express from 'express';
const app = express();
const NAME = /^[A-Za-z0-9 _-]{1,12}$/;
app.get('/api/vote', async (req, res) => {
if (req.query.key !== process.env.VOTE_SECRET) return res.sendStatus(403);
const player = String(req.query.user ?? '');
if (!NAME.test(player)) return res.status(400).send('bad name');
await saveReward(player, 'runelist'); // your own code
res.status(200).send('ok');
});
app.listen(8080);Express. Prefer the name in the path? Save https://play.example/api/vote and use app.get('/api/vote/:player', …) with req.params.player.
https://play.example/api/vote?key=YOUR_SECRET&user=import os
import re
from flask import Flask, request
app = Flask(__name__)
NAME = re.compile(r"[A-Za-z0-9 _-]{1,12}")
@app.get("/api/vote")
def vote():
if request.args.get("key") != os.environ["VOTE_SECRET"]:
return "forbidden", 403
player = request.args.get("user", "")
if not NAME.fullmatch(player):
return "bad name", 400
save_reward(player, "runelist") # your own code
return "ok", 200Flask. The same handler works in FastAPI or Django with their own request objects.
http://YOUR_SERVER_IP:8085/vote?key=YOUR_SECRET&user=import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
public final class VoteCallback {
private static final String SECRET = "YOUR_SECRET";
public static void start() throws Exception {
HttpServer http = HttpServer.create(new InetSocketAddress(8085), 0);
http.createContext("/vote", exchange -> {
Map<String, String> query = new HashMap<>();
String raw = exchange.getRequestURI().getRawQuery();
if (raw != null) {
for (String pair : raw.split("&")) {
String[] kv = pair.split("=", 2);
String value = kv.length > 1 ? kv[1] : "";
query.put(kv[0], URLDecoder.decode(value, StandardCharsets.UTF_8));
}
}
String player = query.getOrDefault("user", "");
int status;
if (!SECRET.equals(query.get("key"))) {
status = 403;
} else if (!player.matches("[A-Za-z0-9 _-]{1,12}")) {
status = 400;
} else {
VoteRewards.add(player, "runelist"); // your own code, thread-safe
status = 200;
}
byte[] body = (status == 200 ? "ok" : "error").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
http.start();
}
}No framework: the HTTP server built into the JDK, so it can run inside your game server. Open the port in your firewall, and hand the reward to your game thread safely.
https://play.example/api/vote?key=YOUR_SECRET&user=using System.Text.RegularExpressions;
var app = WebApplication.CreateBuilder(args).Build();
var secret = Environment.GetEnvironmentVariable("VOTE_SECRET");
app.MapGet("/api/vote", async (string? key, string? user) =>
{
if (secret is null || key != secret) return Results.StatusCode(403);
if (user is null || !Regex.IsMatch(user, "^[A-Za-z0-9 _-]{1,12}$"))
return Results.BadRequest("bad name");
await Rewards.SaveAsync(user, "runelist"); // your own code
return Results.Ok("ok");
});
app.Run();ASP.NET Core minimal API (.NET 6 or newer).
https://play.example/api/vote?key=YOUR_SECRET&user=package main
import (
"net/http"
"os"
"regexp"
)
var name = regexp.MustCompile(`^[A-Za-z0-9 _-]{1,12}$`)
func main() {
http.HandleFunc("/api/vote", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("key") != os.Getenv("VOTE_SECRET") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
player := q.Get("user")
if !name.MatchString(player) {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
if err := saveReward(player, "runelist"); err != nil { // your own code
http.Error(w, "try again", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
http.ListenAndServe(":8080", nil)
}Standard library only.
https://play.example/api/vote?key=YOUR_SECRET&user=require 'sinatra'
get '/api/vote' do
halt 403, 'forbidden' unless params['key'] == ENV['VOTE_SECRET']
player = params['user'].to_s
halt 400, 'bad name' unless player.match?(/\A[A-Za-z0-9 _-]{1,12}\z/)
save_reward(player, 'runelist') # your own code
status 200
'ok'
endSinatra. In Rails, the same checks go in a controller action.
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.
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';https://play.example/vote?user=import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = 'rlcb_your-secret-from-the-edit-page';
export function verifyRuneListCallback(req, user) {
const ts = req.headers['x-runelist-timestamp'] ?? '';
const voteId = req.headers['x-runelist-vote-id'] ?? ''; // empty for the Test button
const sig = String(req.headers['x-runelist-signature'] ?? '');
const expected = createHmac('sha256', SECRET).update(`${ts}.${voteId}.${user}`).digest('hex');
const same =
sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
return same && fresh; // answer 403 when false
}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.
- 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. - 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")
- 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
- Set Place at to First so it runs before your other rules, then Deploy.
- 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.
- 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.
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/YourNameReport 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/withX-Api-Keyand 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": falsewhen that happens.
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}https://runelist.io/api/track/players/const KEY = 'rt_your-key-from-the-runetrack-page';
async function reportPlayers(online) {
const res = await fetch('https://runelist.io/api/track/players/', {
method: 'POST',
headers: { 'X-Api-Key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ players: online }),
});
if (!res.ok) console.warn('RuneList players report failed', res.status);
}
setInterval(() => reportPlayers(getOnlineCount()), 60_000); // every minutehttps://runelist.io/api/track/players/<?php // run from cron every minute
$ch = curl_init('https://runelist.io/api/track/players/');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['X-Api-Key: rt_your-key-from-the-runetrack-page', 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['players' => get_online_count()]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 5,
]);
curl_exec($ch);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-Siteis the toplist:rulocus,rsps-list,top100arena,topg,rune-server,rsps-org,rsps100,moparscape,arena-top100ormoparscape-org. A vote cast on RuneList itself arrives without these headers, exactly as today.X-RuneVote-Vote-Idis RuneVote's id for that vote, the same on every retry.X-RuneList-Vote-Idcarries 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-Sitein 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=Zezimainto?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.