RuneRank Vote Callback API

RuneRank sends a signed HTTP POST to your server within seconds of every counted vote. This page documents exactly what we send, how to verify it, and how to reward players safely.

1. Create your endpoint

You (or your developer) create a publicly reachable HTTPS endpoint on your own infrastructure, for example https://yourserver.com/api/runerank/vote. RuneRank does not host or generate this endpoint. Paste its URL into the Postback URL field under Vote callback in My Servers.

2. The request we send

HTTP request
POST https://yourserver.com/api/runerank/vote
Content-Type: application/json
User-Agent: RuneRank-Callback/1.0
X-RuneRank-Signature: 5f0c...ac9        // HMAC-SHA256, lowercase hex
X-RuneRank-Timestamp: 1757404800        // unix seconds, also in the body
X-RuneRank-Event-Id: c2f4a1e0-...       // same as vote_id, stable across retries
X-RuneRank-Attempt: 1                   // 1 for the first delivery
X-RuneRank-Test: false                  // "true" for test votes

{
  "vote_id":   "c2f4a1e0-8f3d-4a2b-9c11-0f6c7a1b2d34",
  "server_id": "8d1b7f92-3f14-4f6e-a1d2-9b0f21c4e7aa",
  "username":  "Zezima",
  "timestamp": 1757404800,
  "test":      false,
  "attempt":   1,
  "signature": "5f0c...ac9"
}
  • vote_id — unique, immutable event id. Retries reuse the same id.
  • server_id — your RuneRank listing id.
  • username — the in-game name the voter entered (1-20 chars, letters, digits, space, underscore, hyphen). Treat it as a label, never as authentication.
  • timestamp — unix seconds when we signed the request.
  • testtrue only for “Send test vote”. Never grant rewards for these.
  • attempt — delivery attempt number, starting at 1.
  • signature — same value as the X-RuneRank-Signature header.

3. Signature

Every listing has its own signing secret, shown only in your owner dashboard. It is never transmitted in the callback. Compute the HMAC over the raw field string and compare in constant time:

Signing formula
signature = HMAC_SHA256(
  key  = your signing secret,
  data = vote_id + ":" + server_id + ":" + username + ":" + timestamp
)
// lowercase hex encoded

Reject any request whose signature does not match, and reject timestamps more than a few minutes old to limit replay attacks.

4. Examples

Java
// Java (Spark / plain servlet style) — verify, de-duplicate, reward.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;

public class RuneRankCallback {

    private static final String SECRET = System.getenv("RUNERANK_SECRET"); // never hard-code
    private static final long MAX_SKEW_SECONDS = 300; // 5 minutes

    public String handle(String rawBody, String signatureHeader) throws Exception {
        // 1. Parse the RAW body (do not re-serialize before verifying).
        JsonObject json = JsonParser.parseString(rawBody).getAsJsonObject();

        String voteId    = json.get("vote_id").getAsString();
        String serverId  = json.get("server_id").getAsString();
        String username  = json.get("username").getAsString();
        long   timestamp = json.get("timestamp").getAsLong();
        boolean isTest   = json.has("test") && json.get("test").getAsBoolean();

        // 2. Reject stale callbacks (replay window).
        long now = System.currentTimeMillis() / 1000L;
        if (Math.abs(now - timestamp) > MAX_SKEW_SECONDS) {
            return status(401, "stale timestamp");
        }

        // 3. Verify the signature in constant time.
        String data = voteId + ":" + serverId + ":" + username + ":" + timestamp;
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String expected = toHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));

        boolean valid = MessageDigest.isEqual(
            expected.getBytes(StandardCharsets.UTF_8),
            signatureHeader.getBytes(StandardCharsets.UTF_8));
        if (!valid) return status(401, "invalid signature");

        // 4. Test callbacks must never award a real reward.
        if (isTest) return status(200, "ok");

        // 5. Replay / duplicate protection: vote_id is unique and immutable,
        //    retries reuse the same id. Store it with a UNIQUE index.
        if (!VoteStore.insertIfNew(voteId)) return status(200, "already processed");

        // 6. Mark the vote claimable for the player (username is not authentication).
        VoteStore.grantClaimableVote(username, voteId);

        // 7. Reply 2xx within 10 seconds.
        return status(200, "ok");
    }

    private static String toHex(byte[] bytes) {
        StringBuilder sb = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) sb.append(String.format("%02x", b));
        return sb.toString();
    }
}
PHP
<?php
// PHP — /api/runerank/vote
$secret = getenv('RUNERANK_SECRET');
$raw    = file_get_contents('php://input');              // RAW body
$sig    = $_SERVER['HTTP_X_RUNERANK_SIGNATURE'] ?? '';

$body = json_decode($raw, true);
if (!is_array($body)) { http_response_code(400); exit('bad body'); }

if (abs(time() - (int)$body['timestamp']) > 300) { http_response_code(401); exit('stale'); }

$data     = $body['vote_id'] . ':' . $body['server_id'] . ':' . $body['username'] . ':' . $body['timestamp'];
$expected = hash_hmac('sha256', $data, $secret);
if (!hash_equals($expected, $sig)) { http_response_code(401); exit('invalid signature'); }

if (!empty($body['test'])) { http_response_code(200); exit('ok'); }  // never reward test votes

// UNIQUE index on vote_id makes this idempotent across retries.
$stmt = $pdo->prepare('INSERT IGNORE INTO runerank_votes (vote_id, username, claimed) VALUES (?, ?, 0)');
$stmt->execute([$body['vote_id'], $body['username']]);

http_response_code(200);
echo 'ok';
Node.js
// Node.js (Express) — mount the raw body parser for this route.
const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/api/runerank/vote',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const raw = req.body.toString('utf8');
    const sig = req.get('X-RuneRank-Signature') || '';
    const body = JSON.parse(raw);

    if (Math.abs(Date.now() / 1000 - body.timestamp) > 300) return res.status(401).send('stale');

    const data = `${body.vote_id}:${body.server_id}:${body.username}:${body.timestamp}`;
    const expected = crypto.createHmac('sha256', process.env.RUNERANK_SECRET).update(data).digest('hex');

    const a = Buffer.from(expected), b = Buffer.from(sig);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(401).send('invalid signature');

    if (body.test) return res.status(200).send('ok');       // test votes reward nothing

    await db.query(
      'INSERT INTO runerank_votes (vote_id, username) VALUES ($1, $2) ON CONFLICT (vote_id) DO NOTHING',
      [body.vote_id, body.username],
    );

    res.status(200).send('ok');
  });
TypeScript
// TypeScript (Fetch / Hono / Workers style)
import { createHmac, timingSafeEqual } from "node:crypto";

type RuneRankVote = {
  vote_id: string;
  server_id: string;
  username: string;
  timestamp: number;
  test: boolean;
  attempt: number;
  signature: string;
};

export async function handleRuneRankVote(request: Request): Promise<Response> {
  const raw = await request.text();                        // RAW body first
  const header = request.headers.get("x-runerank-signature") ?? "";
  const vote = JSON.parse(raw) as RuneRankVote;

  if (Math.abs(Date.now() / 1000 - vote.timestamp) > 300) {
    return new Response("stale", { status: 401 });
  }

  const data = `${vote.vote_id}:${vote.server_id}:${vote.username}:${vote.timestamp}`;
  const expected = createHmac("sha256", process.env.RUNERANK_SECRET!).update(data).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return new Response("invalid signature", { status: 401 });
  }

  if (vote.test) return new Response("ok");                // never reward test votes

  const isNew = await store.insertVoteIfNew(vote.vote_id, vote.username);
  if (isNew) await store.grantReward(vote.username, vote.vote_id);

  return new Response("ok");                               // 200 within 10 seconds
}

5. Idempotency and retries

Store every processed vote_id with a unique index and ignore ids you have already rewarded. If your endpoint does not answer 2xx within 10 seconds, we retry with exponential backoff (about 5 min, 20 min, 1 h, 4 h, 12 h) for up to 24 hours and 5 attempts — always with the same vote_id.

6. Sending the player identifier

Link players to your RuneRank vote page with their in-game name pre-filled, so the same identifier flows all the way back into your reward system:

Vote URL
https://runerank.com/vote/YOUR-SERVER-SLUG?username=PLAYER_NAME

<!-- From your RSPS website or client -->
<a href="https://runerank.com/vote/alora?username=Zezima">Vote for rewards</a>

The player can still edit the name before voting, and RuneRank validates and sanitises it, so always match it against your own accounts before granting anything valuable.

7. Responding

Reply 200 (any 2xx) within 10 seconds. Queue heavy work instead of doing it inline. Anything else is logged as a failure and retried; you can inspect every attempt in the Recent deliveries table on your dashboard.