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×tamp=1767225600&flagged=0&signature=5f2c…These query parameters are added to your callback URL:
| Parameter | Type | Description |
|---|---|---|
| user | string | The player's in-game name as entered on the vote page. May be empty. |
| server_id | integer | Your RuneIndex listing ID. |
| vote_id | integer | Unique per vote. Use it to make sure each vote is rewarded only once. 0 for test sends. |
| timestamp | integer | Unix 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. |
| signature | hex string | Lowercase 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.
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://vshttp://andwww). - 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, userRuneIndexTestandvote_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:
- Compute the HMAC and compare it to
signaturein constant time. Use theuservalue exactly as received (URL-decoded, same case). - Reject the request if
timestampis 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). - Check that
server_idis your own listing ID. - Store every
vote_idyou'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.
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
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';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);- 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:
- 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}| Field | Type | Description |
|---|---|---|
| players | integer, required | Players online right now, 0 to 50,000. |
| maxPlayers | integer, optional | Your 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.
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 -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.
<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>[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. OptionalserverIdto filter to one server, andlimit(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.
/api/serversList servers. Query: category, search, sort (votes | players | rating | newest), gameMode, version, donationModel (cosmetic | convenience | p2w), page, limit (max 100).
/api/servers/topThe top 100 servers by counted votes this month.
/api/servers/trendingServers gaining votes fastest right now.
/api/servers/launchingUpcoming server launches.
/api/servers/:idDetails for one server, including rank and live player count.
/api/servers/:id/vote-historyDaily counted votes for the last 30 days.
/api/servers/:id/player-historyHourly peak player counts for the last 7 days, from heartbeats.
/api/servers/:id/integrityPublic trust report: counted vs flagged votes, heartbeat and game-check status, callback success rate.
/api/servers/:id/badge.svgEmbeddable rank badge (SVG).
/api/eventsUpcoming events and launches as JSON. Query: serverId, limit (max 200).
/api/events.icsThe same events as an iCal feed for calendar apps.
/api/statsPlatform-wide statistics.
/api/categoriesAll 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.