Vote callback
When a player votes for your server we call a URL on your side, so your game can hand out the reward automatically. Callbacks are available on claimed and verified listings.
Set it up in 5 steps
- Pick a receiver from the examples (or download all as ZIP) and put it on your web host or game server behind HTTPS.
- In your dashboard, set Callback URL (https strongly recommended) and save. A secret is created for you; Generate new secret replaces it, or paste your own (16-128 characters: letters, digits, _ and -).
- Put the same secret in your receiver (the examples read
RSPS_CALLBACK_SECRET). - Press Send test callback. You should see
HTTP 200. The request hastest=1. - Send players to your vote link with their name:
https://runescapeprivateservers.com/vote.php?server=YOUR-SLUG&userid=PLAYERNAME.
The request
An HTTP GET to your callback URL. Your own query parameters stay in front; ours are appended in this order:
GET /vote?secret=...&voted=1&userid=Zezima%20Jr&userip=203.0.113.9&reset=1793800000&voteid=8f3c...&ts=1793756800&test=0| Parameter | Type | Meaning |
|---|---|---|
secret | string | Your callback secret, exactly as saved in the dashboard. Compare it in constant time. |
voted | 0 or 1 | 1 = a new counted vote. 0 = the player already voted inside the last 12 hours (no reward). |
userid | string, 1-32 chars | The player name passed in the vote link (?userid=). Letters, digits, space, _ and - only. |
userip | string | IP address of the voter (IPv4 or IPv6). Useful for your own abuse checks. |
reset | integer (unix time) | When this player can vote again. |
voteid | string, max 64 | Unique id of the vote. Store it and ignore repeats - retries reuse the same id. |
ts | integer (unix time) | When the vote happened. Also part of the signature. |
test | 0 or 1 | 1 = sent by the Send test callback button. Answer 200, give no reward. |
Headers: X-Toplist-Signature: sha256=<hex>, X-Toplist-Timestamp (same as ts), X-Toplist-Vote-Id (same as voteid) and X-Toplist-Attempt (1 for the first try). Values are percent-encoded (RFC 3986, space = %20).
Check the signature
The secret parameter alone proves little if a URL leaks into a log, so also check the HMAC:
- Take the raw query string as you received it, starting at
secret=(drop anything of yours in front). - Build the text
ts + "." + that string. - HMAC-SHA256 it with your secret, hex-encode it, prefix
sha256=and compare withX-Toplist-Signatureusing a constant-time compare. - Reject a
tsolder than 2 days (retries finish within about 9 hours).
import hmac, hashlib
raw = request_query_string # raw query string, as received
signed = raw[raw.index("secret="):] if raw.startswith("secret=") else raw[raw.index("&secret=") + 1:]
expected = "sha256=" + hmac.new(SECRET.encode(), (ts + "." + signed).encode(), hashlib.sha256).hexdigest()
ok = hmac.compare_digest(expected, headers["X-Toplist-Signature"])What your receiver must do
- Answer 200 quickly (under 5 seconds). Do slow work (database, giving items) after answering or in a queue.
- Be idempotent: save
voteidand give nothing if you've seen it before. A retry can arrive after your first answer timed out. - Only reward when
voted=1andtest=0. - Validate
userid; never put it into SQL or a shell without escaping.
Test mode
The dashboard's Send test callback sends a real signed request with test=1, voted=1, a vote id starting with test- and the test player name you typed as userid. It shows whether your server answered with a 2xx status. Test requests are never retried and are limited to a few per hour.
Retries
If we don't get a 2xx answer (timeout, error, 5xx), we retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then give up. Redirects are not followed. The callback host must resolve to a public IP address.
Receiver examples
Each one checks the secret and signature, validates the player name, ignores repeats by vote id and returns 200. Download ZIP
PHP (standalone)
<?php
/**
* Standalone vote callback receiver (PHP 7.4+, no database).
* URL to enter in your dashboard: https://yourserver.com/callback.php
* Rewards are appended to pending_rewards.jsonl; your game server reads it and gives the reward.
*/
declare(strict_types=1);
$secret = getenv('RSPS_CALLBACK_SECRET') ?: 'change-me-to-your-callback-secret';
$dataDir = getenv('RSPS_CALLBACK_DATA') ?: __DIR__;
function done(int $code, string $msg): void
{
http_response_code($code);
header('Content-Type: text/plain');
exit($msg);
}
$raw = (string)($_SERVER['QUERY_STRING'] ?? '');
$pos = strpos($raw, 'secret=') === 0 ? 0 : strpos($raw, '&secret=');
if ($pos === false) {
done(400, 'missing secret');
}
$signed = $pos === 0 ? $raw : substr($raw, $pos + 1);
$ts = (string)($_GET['ts'] ?? '');
$sig = (string)($_SERVER['HTTP_X_TOPLIST_SIGNATURE'] ?? '');
$expect = 'sha256=' . hash_hmac('sha256', $ts . '.' . $signed, $secret);
if (!hash_equals($secret, (string)($_GET['secret'] ?? '')) || !hash_equals($expect, $sig)) {
done(403, 'bad signature');
}
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 172800) {
done(403, 'stale');
}
if (($_GET['test'] ?? '0') === '1') {
done(200, 'OK test');
}
$user = (string)($_GET['userid'] ?? '');
$voteId = (string)($_GET['voteid'] ?? '');
if (!preg_match('/^[A-Za-z0-9 _-]{1,32}$/', $user) || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $voteId)) {
done(400, 'bad userid');
}
if (($_GET['voted'] ?? '0') !== '1') {
done(200, 'OK not counted'); // duplicate vote inside 12h: no reward
}
$fh = fopen($dataDir . '/pending_rewards.jsonl', 'c+');
flock($fh, LOCK_EX);
$seen = false;
while (($line = fgets($fh)) !== false) {
$r = json_decode($line, true);
if (($r['vote_id'] ?? '') === $voteId) {
$seen = true;
break;
}
}
if (!$seen) {
fseek($fh, 0, SEEK_END);
fwrite($fh, json_encode(['vote_id' => $voteId, 'player' => $user, 'at' => time(), 'given' => false]) . "\n");
}
flock($fh, LOCK_UN);
fclose($fh);
done(200, $seen ? 'OK duplicate' : 'OK');PHP + PDO / MySQL
<?php
/**
* Vote callback receiver with PDO (MySQL/MariaDB or SQLite). PHP 7.4+.
* Creates table vote_rewards (vote_id PRIMARY KEY) - the primary key makes retries idempotent.
* Your game server: SELECT * FROM vote_rewards WHERE player=? AND given=0, give items, then UPDATE given=1.
*/
declare(strict_types=1);
$secret = getenv('RSPS_CALLBACK_SECRET') ?: 'change-me-to-your-callback-secret';
function done(int $code, string $msg): void
{
http_response_code($code);
header('Content-Type: text/plain');
exit($msg);
}
$raw = (string)($_SERVER['QUERY_STRING'] ?? '');
$pos = strpos($raw, 'secret=') === 0 ? 0 : strpos($raw, '&secret=');
if ($pos === false) {
done(400, 'missing secret');
}
$signed = $pos === 0 ? $raw : substr($raw, $pos + 1);
$ts = (string)($_GET['ts'] ?? '');
$expect = 'sha256=' . hash_hmac('sha256', $ts . '.' . $signed, $secret);
if (!hash_equals($secret, (string)($_GET['secret'] ?? '')) || !hash_equals($expect, (string)($_SERVER['HTTP_X_TOPLIST_SIGNATURE'] ?? ''))) {
done(403, 'bad signature');
}
if (!ctype_digit($ts) || abs(time() - (int)$ts) > 172800) {
done(403, 'stale');
}
if (($_GET['test'] ?? '0') === '1') {
done(200, 'OK test');
}
$user = (string)($_GET['userid'] ?? '');
$voteId = (string)($_GET['voteid'] ?? '');
if (!preg_match('/^[A-Za-z0-9 _-]{1,32}$/', $user) || !preg_match('/^[A-Za-z0-9_-]{1,64}$/', $voteId)) {
done(400, 'bad userid');
}
if (($_GET['voted'] ?? '0') !== '1') {
done(200, 'OK not counted');
}
try {
$pdo = new PDO(getenv('RSPS_DB_DSN') ?: 'mysql:host=localhost;dbname=game;charset=utf8mb4',
getenv('RSPS_DB_USER') ?: null, getenv('RSPS_DB_PASS') ?: null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$pdo->exec('CREATE TABLE IF NOT EXISTS vote_rewards (vote_id VARCHAR(64) PRIMARY KEY, player VARCHAR(32) NOT NULL,
created_at INTEGER NOT NULL, given SMALLINT NOT NULL DEFAULT 0)');
$st = $pdo->prepare('INSERT INTO vote_rewards (vote_id, player, created_at) VALUES (?, ?, ?)');
$st->execute([$voteId, $user, time()]);
done(200, 'OK');
} catch (PDOException $e) {
if ($e->getCode() === '23000') { // duplicate primary key = already credited (a retry)
done(200, 'OK duplicate');
}
error_log('vote callback DB error: ' . $e->getMessage());
done(500, 'db error'); // non-200 -> the toplist retries later
}Java (JDK HttpServer)
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.regex.Pattern;
/**
* Vote callback receiver using only the JDK (11+). Run: java VoteCallback.java 8090
* Callback URL for your dashboard: https://yourserver.com:8090/vote (put it behind your HTTPS proxy)
*
* In your game server, call VoteCallback.claimRewards(playerName) on login and give one reward per entry.
* Persist PROCESSED and PENDING (file or database) in production - this demo keeps them in memory.
*/
public final class VoteCallback {
static final String SECRET = Optional.ofNullable(System.getenv("RSPS_CALLBACK_SECRET")).orElse("change-me-to-your-callback-secret");
static final Pattern USER = Pattern.compile("^[A-Za-z0-9 _-]{1,32}$");
static final Pattern VOTE_ID = Pattern.compile("^[A-Za-z0-9_-]{1,64}$");
static final Set<String> PROCESSED = ConcurrentHashMap.newKeySet();
static final Map<String, Integer> PENDING = new ConcurrentHashMap<>(); // lower-case player name -> rewards waiting
public static void main(String[] args) throws IOException {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 8090;
HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext("/vote", VoteCallback::handle);
server.setExecutor(Executors.newFixedThreadPool(4));
server.start();
System.out.println("vote callback listening on :" + port + "/vote");
}
/** Game-server hook: returns how many vote rewards to give this player now, and clears them. */
public static int claimRewards(String player) {
Integer n = PENDING.remove(player.toLowerCase(Locale.ROOT));
return n == null ? 0 : n;
}
static void handle(HttpExchange ex) throws IOException {
try {
String raw = Optional.ofNullable(ex.getRequestURI().getRawQuery()).orElse("");
int pos = raw.startsWith("secret=") ? 0 : raw.indexOf("&secret=");
if (pos < 0) { reply(ex, 400, "missing secret"); return; }
String signed = pos == 0 ? raw : raw.substring(pos + 1);
Map<String, String> q = parse(raw);
String ts = q.getOrDefault("ts", "");
String expect = "sha256=" + hmacHex(SECRET, ts + "." + signed);
String got = Optional.ofNullable(ex.getRequestHeaders().getFirst("X-Toplist-Signature")).orElse("");
if (!constEq(SECRET, q.getOrDefault("secret", "")) || !constEq(expect, got)) { reply(ex, 403, "bad signature"); return; }
if (!ts.matches("\\d{1,12}") || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) > 172800) { reply(ex, 403, "stale"); return; }
if ("1".equals(q.get("test"))) { reply(ex, 200, "OK test"); return; }
String user = q.getOrDefault("userid", ""), voteId = q.getOrDefault("voteid", "");
if (!USER.matcher(user).matches() || !VOTE_ID.matcher(voteId).matches()) { reply(ex, 400, "bad userid"); return; }
if (!"1".equals(q.get("voted"))) { reply(ex, 200, "OK not counted"); return; }
if (!PROCESSED.add(voteId)) { reply(ex, 200, "OK duplicate"); return; }
PENDING.merge(user.toLowerCase(Locale.ROOT), 1, Integer::sum);
reply(ex, 200, "OK");
} catch (Exception e) {
reply(ex, 500, "error");
}
}
static Map<String, String> parse(String raw) {
Map<String, String> m = new HashMap<>();
for (String kv : raw.split("&")) {
int i = kv.indexOf('=');
if (i > 0) m.putIfAbsent(URLDecoder.decode(kv.substring(0, i), StandardCharsets.UTF_8),
URLDecoder.decode(kv.substring(i + 1), StandardCharsets.UTF_8));
}
return m;
}
static String hmacHex(String key, String msg) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
StringBuilder sb = new StringBuilder();
for (byte b : mac.doFinal(msg.getBytes(StandardCharsets.UTF_8))) sb.append(String.format("%02x", b));
return sb.toString();
}
static boolean constEq(String a, String b) {
return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}
static void reply(HttpExchange ex, int code, String body) throws IOException {
byte[] b = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "text/plain");
ex.sendResponseHeaders(code, b.length);
try (OutputStream os = ex.getResponseBody()) { os.write(b); }
}
}Kotlin
// Vote callback receiver in Kotlin, JDK HttpServer only (no libraries).
// Build: kotlinc VoteCallback.kt -include-runtime -d cb.jar && java -jar cb.jar 8090
// Game hook: VoteRewards.claim(playerName) on login -> number of rewards to give.
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.net.URLDecoder
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
val SECRET: String = System.getenv("RSPS_CALLBACK_SECRET") ?: "change-me-to-your-callback-secret"
private val USER = Regex("^[A-Za-z0-9 _-]{1,32}$")
private val VOTE_ID = Regex("^[A-Za-z0-9_-]{1,64}$")
object VoteRewards {
val processed: MutableSet<String> = ConcurrentHashMap.newKeySet()
val pending = ConcurrentHashMap<String, Int>()
fun claim(player: String): Int = pending.remove(player.lowercase()) ?: 0
}
fun hmacHex(key: String, msg: String): String {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(key.toByteArray(), "HmacSHA256"))
return mac.doFinal(msg.toByteArray()).joinToString("") { "%02x".format(it) }
}
fun constEq(a: String, b: String) = MessageDigest.isEqual(a.toByteArray(), b.toByteArray())
fun reply(ex: HttpExchange, code: Int, body: String) {
val b = body.toByteArray()
ex.responseHeaders.set("Content-Type", "text/plain")
ex.sendResponseHeaders(code, b.size.toLong())
ex.responseBody.use { it.write(b) }
}
fun handle(ex: HttpExchange) {
val raw = ex.requestURI.rawQuery ?: ""
val pos = if (raw.startsWith("secret=")) 0 else raw.indexOf("&secret=")
if (pos < 0) return reply(ex, 400, "missing secret")
val signed = if (pos == 0) raw else raw.substring(pos + 1)
val q = raw.split("&").mapNotNull { kv ->
val i = kv.indexOf('='); if (i <= 0) null else URLDecoder.decode(kv.substring(0, i), "UTF-8") to URLDecoder.decode(kv.substring(i + 1), "UTF-8")
}.reversed().toMap()
val ts = q["ts"] ?: ""
val sig = ex.requestHeaders.getFirst("X-Toplist-Signature") ?: ""
if (!constEq(SECRET, q["secret"] ?: "") || !constEq("sha256=" + hmacHex(SECRET, "$ts.$signed"), sig)) return reply(ex, 403, "bad signature")
val t = ts.toLongOrNull() ?: return reply(ex, 403, "stale")
if (kotlin.math.abs(System.currentTimeMillis() / 1000 - t) > 172800) return reply(ex, 403, "stale")
if (q["test"] == "1") return reply(ex, 200, "OK test")
val user = q["userid"] ?: ""
val voteId = q["voteid"] ?: ""
if (!USER.matches(user) || !VOTE_ID.matches(voteId)) return reply(ex, 400, "bad userid")
if (q["voted"] != "1") return reply(ex, 200, "OK not counted")
if (!VoteRewards.processed.add(voteId)) return reply(ex, 200, "OK duplicate")
VoteRewards.pending.merge(user.lowercase(), 1, Int::plus)
reply(ex, 200, "OK")
}
fun main(args: Array<String>) {
val port = args.firstOrNull()?.toInt() ?: 8090
val server = HttpServer.create(InetSocketAddress(port), 0)
server.createContext("/vote") { ex -> try { handle(ex) } catch (e: Exception) { reply(ex, 500, "error") } }
server.start()
println("vote callback listening on :$port/vote")
}Node.js (Express)
// Vote callback receiver - Node.js 18+ with Express 4. npm i express@4 && node server.js
// Game hook: claimRewards(player) on login. Persist `processed` / `pending` in production.
'use strict';
const express = require('express');
const crypto = require('crypto');
const SECRET = process.env.RSPS_CALLBACK_SECRET || 'change-me-to-your-callback-secret';
const processed = new Set();
const pending = new Map(); // lower-case player -> rewards waiting
const app = express();
const constEq = (a, b) => {
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
};
app.get('/vote', (req, res) => {
const raw = (req.originalUrl.split('?')[1] || '');
const pos = raw.startsWith('secret=') ? 0 : raw.indexOf('&secret=');
if (pos < 0) return res.status(400).type('text').send('missing secret');
const signed = pos === 0 ? raw : raw.slice(pos + 1);
const q = req.query;
const ts = String(q.ts || '');
const expect = 'sha256=' + crypto.createHmac('sha256', SECRET).update(ts + '.' + signed).digest('hex');
if (!constEq(SECRET, q.secret || '') || !constEq(expect, req.get('X-Toplist-Signature') || '')) {
return res.status(403).type('text').send('bad signature');
}
if (!/^\d{1,12}$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 172800) return res.status(403).type('text').send('stale');
if (q.test === '1') return res.type('text').send('OK test');
const user = String(q.userid || ''), voteId = String(q.voteid || '');
if (!/^[A-Za-z0-9 _-]{1,32}$/.test(user) || !/^[A-Za-z0-9_-]{1,64}$/.test(voteId)) return res.status(400).type('text').send('bad userid');
if (q.voted !== '1') return res.type('text').send('OK not counted');
if (processed.has(voteId)) return res.type('text').send('OK duplicate');
processed.add(voteId);
const k = user.toLowerCase();
pending.set(k, (pending.get(k) || 0) + 1);
res.type('text').send('OK');
});
function claimRewards(player) {
const k = String(player).toLowerCase(), n = pending.get(k) || 0;
pending.delete(k);
return n;
}
module.exports = { app, claimRewards };
if (require.main === module) {
const port = Number(process.env.PORT || 8090);
app.listen(port, () => console.log(`vote callback listening on :${port}/vote`));
}Python (Flask)
"""Vote callback receiver - Python 3.8+ with Flask. pip install flask && python app.py
Game hook: claim_rewards(player) on login. Persist PROCESSED / PENDING in production."""
import hashlib
import hmac
import os
import re
import threading
import time
from flask import Flask, request
SECRET = os.environ.get("RSPS_CALLBACK_SECRET", "change-me-to-your-callback-secret")
USER = re.compile(r"^[A-Za-z0-9 _-]{1,32}$")
VOTE_ID = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
PROCESSED: set = set()
PENDING: dict = {}
LOCK = threading.Lock()
app = Flask(__name__)
def text(body, code=200):
return body, code, {"Content-Type": "text/plain"}
@app.get("/vote")
def vote():
raw = request.query_string.decode("ascii", "replace")
pos = 0 if raw.startswith("secret=") else raw.find("&secret=")
if pos < 0:
return text("missing secret", 400)
signed = raw if pos == 0 else raw[pos + 1:]
q = request.args
ts = q.get("ts", "")
expect = "sha256=" + hmac.new(SECRET.encode(), f"{ts}.{signed}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(SECRET, q.get("secret", "")) or not hmac.compare_digest(expect, request.headers.get("X-Toplist-Signature", "")):
return text("bad signature", 403)
if not ts.isdigit() or abs(time.time() - int(ts)) > 172800:
return text("stale", 403)
if q.get("test") == "1":
return text("OK test")
user, vote_id = q.get("userid", ""), q.get("voteid", "")
if not USER.match(user) or not VOTE_ID.match(vote_id):
return text("bad userid", 400)
if q.get("voted") != "1":
return text("OK not counted")
with LOCK:
if vote_id in PROCESSED:
return text("OK duplicate")
PROCESSED.add(vote_id)
PENDING[user.lower()] = PENDING.get(user.lower(), 0) + 1
return text("OK")
def claim_rewards(player: str) -> int:
with LOCK:
return PENDING.pop(player.lower(), 0)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8090")))Troubleshooting
- Test callback shows "refused"
- The URL must use http or https on a public host name (no private or local IP ranges, no localhost), answer within 5 seconds and not redirect.
- 403 bad signature
- Your secret differs from the dashboard, or a proxy rewrote the query string. Sign the raw string from
secret=to the end, exactly as received. - Players get rewarded twice
- Store
voteidbefore answering and check it first. - No callback at all
- The vote link needs
&userid=; votes without a player name are counted but not sent. Unclaimed listings never send callbacks. - Votes are capped
- One vote per player name and per IP address every 12 hours. Votes that fail our anti-cheat audit are removed and won't count.