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

  1. Pick a receiver from the examples (or download all as ZIP) and put it on your web host or game server behind HTTPS.
  2. 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 -).
  3. Put the same secret in your receiver (the examples read RSPS_CALLBACK_SECRET).
  4. Press Send test callback. You should see HTTP 200. The request has test=1.
  5. 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:

Example
GET /vote?secret=...&voted=1&userid=Zezima%20Jr&userip=203.0.113.9&reset=1793800000&voteid=8f3c...&ts=1793756800&test=0
ParameterTypeMeaning
secretstringYour callback secret, exactly as saved in the dashboard. Compare it in constant time.
voted0 or 11 = a new counted vote. 0 = the player already voted inside the last 12 hours (no reward).
useridstring, 1-32 charsThe player name passed in the vote link (?userid=). Letters, digits, space, _ and - only.
useripstringIP address of the voter (IPv4 or IPv6). Useful for your own abuse checks.
resetinteger (unix time)When this player can vote again.
voteidstring, max 64Unique id of the vote. Store it and ignore repeats - retries reuse the same id.
tsinteger (unix time)When the vote happened. Also part of the signature.
test0 or 11 = 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:

  1. Take the raw query string as you received it, starting at secret= (drop anything of yours in front).
  2. Build the text ts + "." + that string.
  3. HMAC-SHA256 it with your secret, hex-encode it, prefix sha256= and compare with X-Toplist-Signature using a constant-time compare.
  4. Reject a ts older than 2 days (retries finish within about 9 hours).
The check in a few lines
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

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/callback.php
<?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/callback_pdo.php
<?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)

java/VoteCallback.java
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

kotlin/VoteCallback.kt
// 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)

node/server.js
// 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)

python/app.py
"""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 voteid before 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.