RSPS Vote Callback & Integration API

Everything a server owner needs to connect their RSPS to RuneIndex: pay out vote rewards automatically with signed vote callbacks, show a verified live player count, prove your game server is online, and embed your rank on your website or forum. Examples are in Java, PHP and Node.js.

You'll find your callback URL setting, API secret and delivery log in your listing's dashboard under the Integration tab.

Vote Callbacks: Automatic RSPS Vote Rewards

When a player votes for your server, RuneIndex sends an HTTP GET request to the callback URL you set in your dashboard (Integration tab). Your endpoint checks the request and gives the player their reward: vote points, items, donor credit, whatever you like.

GET https://yourserver.com/vote-callback?user=Zezima&server_id=123&vote_id=48213&timestamp=1767225600&flagged=0&signature=5f2c…

These query parameters are added to your callback URL:

ParameterTypeDescription
userstringThe player's in-game name as entered on the vote page. May be empty.
server_idintegerYour RuneIndex listing ID.
vote_idintegerUnique per vote. Use it to make sure each vote is rewarded only once. 0 for test sends.
timestampintegerUnix time (seconds) when this delivery attempt was signed.
flagged"0" | "1"1 if the vote came from a VPN, proxy or hosting network. It is still a real vote, so you can reward it if you like, but it does not count toward your rank.
test"1"Only present on test sends from your dashboard. Don't hand out real rewards for these.
signaturehex stringLowercase hex HMAC-SHA256 of the vote. See Verifying signatures.

The signature and timestamp are also sent as the X-RuneIndex-Signature and X-RuneIndex-Timestamp headers, and requests come with the User-Agent RuneIndex-Callback/2.

Upgrading an older integration? The user and server_id parameters haven't changed, so your existing handler still works. But anyone who finds your callback URL can call it and hand themselves rewards, so add signature verification (below) as soon as you can.
  • Respond with any 2xx status within 5 seconds. A non-2xx status, a timeout, a connection error or a redirect all count as a failure and the delivery is retried.
  • Callbacks never follow redirects. Use the final URL (including https:// vs http:// and www).
  • The URL must be reachable from the public internet. URLs pointing at private or internal IP addresses are refused.
  • Send a test callback from the Integration tab to check your endpoint. Test sends have test=1, user RuneIndexTest and vote_id=0.

Verifying Signatures

The signature is a lowercase hex HMAC-SHA256. The key is your listing's API secret (dashboard → Integration), used exactly as shown there. Don't hex-decode it. The message is this exact string:

{server_id}:{vote_id}:{user}:{timestamp}

e.g.  123:48213:Zezima:1767225600
      123:48214::1767225660        (no username given: user is an empty string)

Before rewarding a vote, your handler should:

  1. Compute the HMAC and compare it to signature in constant time. Use the user value exactly as received (URL-decoded, same case).
  2. Reject the request if timestamp is more than 300 seconds from your clock. Each retry is signed again with a fresh timestamp, so this won't block retries. Keep your server clock synced (NTP).
  3. Check that server_id is your own listing ID.
  4. Store every vote_id you've processed and ignore repeats. If your response gets lost, a retry can deliver the same vote twice. Still reply 2xx to a repeat so it isn't retried again.
Java
Standalone listener using the JDK's built-in HTTP server (no dependencies)
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.UnsupportedEncodingException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public final class RuneIndexVoteListener {

    private static final String API_SECRET = "your-api-secret"; // Dashboard -> Integration
    private static final int SERVER_ID = 123;                    // your RuneIndex listing ID
    private static final long MAX_SKEW_SECONDS = 300;

    // In production keep this in your database so a restart doesn't forget paid votes.
    private static final Set<Long> processedVotes = ConcurrentHashMap.newKeySet();

    /** Call once on startup, e.g. RuneIndexVoteListener.start(8080); */
    public static void start(int port) throws IOException {
        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);
        server.createContext("/vote-callback", RuneIndexVoteListener::handle);
        server.start();
    }

    private static void handle(HttpExchange exchange) throws IOException {
        int status;
        try {
            status = process(parseQuery(exchange.getRequestURI().getRawQuery()));
        } catch (Exception e) {
            status = 400;
        }
        exchange.sendResponseHeaders(status, -1);
        exchange.close();
    }

    private static int process(Map<String, String> q) throws Exception {
        String user = q.getOrDefault("user", "");
        String serverId = q.getOrDefault("server_id", "");
        String voteId = q.getOrDefault("vote_id", "");
        String timestamp = q.getOrDefault("timestamp", "");
        String signature = q.getOrDefault("signature", "");

        // 1. The signature must match (constant-time comparison).
        String expected = hmacSha256Hex(API_SECRET, serverId + ":" + voteId + ":" + user + ":" + timestamp);
        if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
                signature.getBytes(StandardCharsets.UTF_8))) {
            return 403;
        }

        // 2. It must be fresh (blocks replays of an old, captured request).
        long now = System.currentTimeMillis() / 1000;
        if (Math.abs(now - Long.parseLong(timestamp)) > MAX_SKEW_SECONDS) return 403;

        // 3. It must be for your listing.
        if (Integer.parseInt(serverId) != SERVER_ID) return 403;

        // Test send from the dashboard: acknowledge, no reward.
        if ("1".equals(q.get("test"))) return 200;

        // 4. Reward each vote once. A retry of a vote you already paid still gets a 200.
        if (!processedVotes.add(Long.parseLong(voteId))) return 200;

        boolean flagged = "1".equals(q.get("flagged"));
        rewardPlayer(user, flagged);
        return 200;
    }

    private static void rewardPlayer(String username, boolean flagged) {
        // This runs on the HTTP thread: hand the reward to your game thread instead of
        // touching game state here, and store it for later if the player is offline.
        // e.g. GameEngine.submit(() -> VoteRewards.give(username));
    }

    private static String hmacSha256Hex(String key, String message) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        byte[] digest = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
        StringBuilder hex = new StringBuilder(digest.length * 2);
        for (byte b : digest) hex.append(String.format("%02x", b));
        return hex.toString();
    }

    private static Map<String, String> parseQuery(String raw) throws UnsupportedEncodingException {
        Map<String, String> out = new HashMap<>();
        if (raw == null || raw.isEmpty()) return out;
        for (String pair : raw.split("&")) {
            int eq = pair.indexOf('=');
            String key = eq >= 0 ? pair.substring(0, eq) : pair;
            String value = eq >= 0 ? pair.substring(eq + 1) : "";
            out.put(URLDecoder.decode(key, "UTF-8"), URLDecoder.decode(value, "UTF-8"));
        }
        return out;
    }
}
PHP
Callback script with MySQL de-duplication
<?php
const API_SECRET = 'your-api-secret'; // Dashboard -> Integration
const SERVER_ID  = 123;               // your RuneIndex listing ID

$user      = (string)($_GET['user'] ?? '');
$serverId  = (string)($_GET['server_id'] ?? '');
$voteId    = (string)($_GET['vote_id'] ?? '');
$timestamp = (string)($_GET['timestamp'] ?? '');
$signature = (string)($_GET['signature'] ?? '');

// 1. Signature (hash_equals is constant-time)
$expected = hash_hmac('sha256', $serverId . ':' . $voteId . ':' . $user . ':' . $timestamp, API_SECRET);
if (!hash_equals($expected, $signature)) { http_response_code(403); exit; }

// 2. Freshness: reject anything more than 5 minutes off
if (abs(time() - (int)$timestamp) > 300) { http_response_code(403); exit; }

// 3. Your listing
if ((int)$serverId !== SERVER_ID) { http_response_code(403); exit; }

// Test send from the dashboard: acknowledge, no reward
if (($_GET['test'] ?? '') === '1') { http_response_code(200); exit('ok'); }

// 4. Reward each vote once. vote_id is the PRIMARY KEY, so a retried delivery is ignored.
$pdo  = new PDO('mysql:host=localhost;dbname=rsps', 'db_user', 'db_pass');
$stmt = $pdo->prepare('INSERT IGNORE INTO runeindex_votes (vote_id, username, flagged) VALUES (?, ?, ?)');
$stmt->execute([(int)$voteId, $user, ($_GET['flagged'] ?? '') === '1' ? 1 : 0]);

if ($stmt->rowCount() === 1) {
    // New vote: queue the reward for your game server to pay out
    // (e.g. insert into a pending_rewards table it polls).
}

http_response_code(200);
echo 'ok';
Node.js
Express handler
const crypto = require('crypto');
const express = require('express');

const API_SECRET = process.env.RUNEINDEX_SECRET; // Dashboard -> Integration
const SERVER_ID = 123;                           // your RuneIndex listing ID
const processed = new Set(); // use your database in production

const app = express();

app.get('/vote-callback', async (req, res) => {
  const str = (v) => (typeof v === 'string' ? v : '');
  const user = str(req.query.user);
  const serverId = str(req.query.server_id);
  const voteId = str(req.query.vote_id);
  const timestamp = str(req.query.timestamp);
  const signature = str(req.query.signature);

  // 1. Signature (timingSafeEqual throws on unequal lengths, so check first)
  const expected = crypto
    .createHmac('sha256', API_SECRET)
    .update(`${serverId}:${voteId}:${user}:${timestamp}`)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(403);

  // 2. Freshness
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(403);

  // 3. Your listing
  if (Number(serverId) !== SERVER_ID) return res.sendStatus(403);

  // Test send from the dashboard: acknowledge, no reward
  if (req.query.test === '1') return res.send('ok');

  // 4. Reward each vote once
  if (processed.has(voteId)) return res.send('ok');
  processed.add(voteId);

  await rewardPlayer(user, { flagged: req.query.flagged === '1' }); // your code: keep it fast
  res.send('ok');
});

app.listen(8080);
How votes are protected
  • One vote per server every 12 hours per IP address and per username (case-insensitive).
  • Captcha and VPN/proxy screening where enabled.
  • Votes from VPNs, proxies or hosting networks are delivered with flagged=1. You can still reward them, but they don't count toward your rank.

Retries & Delivery Log

If your endpoint is down or slow, you won't lose the vote. Failed deliveries are retried on this schedule:

Immediately
+1 min
+5 min
+15 min
+1 h
+3 h
  • That's 6 attempts over about 4.5 hours. If none succeed, the delivery is marked failed.
  • Every attempt appears in the delivery log in your dashboard, with the HTTP status and error, so you can see exactly why a callback failed.
  • Test sends are not retried.
  • Retries keep the same vote_id. That's why de-duplicating on it matters.

Player Count Heartbeat

RuneIndex only shows player counts that your game server reports itself. There is no way to type a number in. Have your server send its online count about every 60 seconds:

POST https://runeindex.org/api/servers/{id}/heartbeat
Authorization: Bearer <your API secret>
Content-Type: application/json

{"players": 123, "maxPlayers": 2000}
FieldTypeDescription
playersinteger, requiredPlayers online right now, 0 to 50,000.
maxPlayersinteger, optionalYour player cap. If you send it, players can't be higher than it or the request is rejected.

The response is {"accepted":true,"recorded":true}. Calls less than 30 seconds apart are accepted but not recorded ("recorded":false). A missing or wrong secret returns 401.

  • A count stays live for 10 minutes after your last heartbeat. After that, your listing shows no player count until heartbeats resume.
  • Counts that stay exactly the same across 12+ hours of samples are flagged as fabricated and hidden until they start moving again.
  • The player graph on your listing (hourly peaks over 7 days) is drawn from your heartbeats.
  • Rotating your API secret in the dashboard invalidates the old one immediately. Update your game server's config right after.
Keep the secret on your server. Send heartbeats from the game server itself. Never put the API secret in your client or launcher: anyone can pull it out and use it to fake your player count or forge vote callbacks.
Java
Java 11+ HttpClient, every 60 seconds
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public final class RuneIndexHeartbeat {

    private static final String URL = "https://runeindex.org/api/servers/123/heartbeat";
    private static final String API_SECRET = System.getenv("RUNEINDEX_SECRET");
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    /** Call once on startup. Requires Java 11+. */
    public static void start() {
        ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
            Thread t = new Thread(r, "runeindex-heartbeat");
            t.setDaemon(true);
            return t;
        });
        scheduler.scheduleAtFixedRate(RuneIndexHeartbeat::send, 0, 60, TimeUnit.SECONDS);
    }

    private static void send() {
        try {
            int players = World.getPlayers().size(); // your real online player count
            String body = "{\"players\":" + players + ",\"maxPlayers\":2000}";

            HttpRequest request = HttpRequest.newBuilder(URI.create(URL))
                    .timeout(Duration.ofSeconds(10))
                    .header("Authorization", "Bearer " + API_SECRET)
                    .header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofString(body))
                    .build();

            HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
            if (response.statusCode() != 200) {
                System.err.println("RuneIndex heartbeat failed: HTTP " + response.statusCode() + " " + response.body());
            }
        } catch (Exception e) {
            // Never let an exception escape: it would cancel the scheduled task for good.
            System.err.println("RuneIndex heartbeat error: " + e.getMessage());
        }
    }
}
curl
Quick test from a terminal
curl -X POST https://runeindex.org/api/servers/123/heartbeat \
  -H "Authorization: Bearer $RUNEINDEX_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"players": 123, "maxPlayers": 2000}'

# {"accepted":true,"recorded":true}

Game Server Online Check

By default, the Online status on your listing reflects your website. If you enter your game server's host and port (1024 to 65535) in the dashboard, it reflects the game server instead.

  • About every 10 minutes, RuneIndex opens a TCP connection to that host and port, sends the standard login-handshake opcode 14, and disconnects. To your server it looks like an abandoned login.
  • Your game host and port are never shown publicly.

Rank Badge

Show your live RuneIndex rank and this month's vote count on your website or forum. The badge is an SVG at https://runeindex.org/api/servers/{id}/badge.svg and is cached for about 10 minutes. Link it to your listing so players can click through to vote.

HTML
Website
<a href="https://runeindex.org/servers/123">
  <img src="https://runeindex.org/api/servers/123/badge.svg" alt="Vote for us on RuneIndex" height="28">
</a>
BBCode
Forums
[url=https://runeindex.org/servers/123][img]https://runeindex.org/api/servers/123/badge.svg[/img][/url]

Replace 123 with your listing ID. The ID-only listing URL always works. It takes visitors to your listing page at its full address.

Events & Launches

Post launches, updates and in-game events from your dashboard. Your listing's launch date is included automatically as a launch event. Events are published in two formats:

  • GET /api/events: upcoming events as JSON. Optional serverId to filter to one server, and limit (max 200).
  • https://runeindex.org/api/events.ics: an iCal feed players can subscribe to in Google Calendar, Apple Calendar or Outlook.
  • GET /api/servers/launching: servers with upcoming launch dates.
curl "https://runeindex.org/api/events?serverId=123&limit=20"

Public API Endpoints

These read-only JSON endpoints need no authentication. Base URL: https://runeindex.org. Please cache responses and keep request rates reasonable.

GET
/api/servers

List servers. Query: category, search, sort (votes | players | rating | newest), gameMode, version, donationModel (cosmetic | convenience | p2w), page, limit (max 100).

GET
/api/servers/top

The top 100 servers by counted votes this month.

GET
/api/servers/trending

Servers gaining votes fastest right now.

GET
/api/servers/launching

Upcoming server launches.

GET
/api/servers/:id

Details for one server, including rank and live player count.

GET
/api/servers/:id/vote-history

Daily counted votes for the last 30 days.

GET
/api/servers/:id/player-history

Hourly peak player counts for the last 7 days, from heartbeats.

GET
/api/servers/:id/integrity

Public trust report: counted vs flagged votes, heartbeat and game-check status, callback success rate.

GET
/api/servers/:id/badge.svg

Embeddable rank badge (SVG).

GET
/api/events

Upcoming events and launches as JSON. Query: serverId, limit (max 200).

GET
/api/events.ics

The same events as an iCal feed for calendar apps.

GET
/api/stats

Platform-wide statistics.

GET
/api/categories

All server categories.

Need Help?

If you're having trouble setting up vote callbacks or the heartbeat, check the delivery log in your dashboard first. It shows the exact HTTP status and error for every attempt. Still stuck? Send us a message with your server ID and a description of the issue.