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
- Always GET. Nothing is sent in a request body, and there is no signature header.
- Both
https://andhttp://work, and redirects are followed. Use https when you can. - 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. It has to be exactly 200;
201,204and every other code count 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 3 more times: after 30 s, then 2 min, then 10 min. Retries wait up to 5 seconds for your answer. After the last one we stop.
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.
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://test.runelist.io/toplist/server/{your id}/vote/YourNameWhen 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.
- Answering 204 or a redirect to an error page is not a 200.
Still stuck? Ask in our Discord and include the line from your callback log.