The directory is readable through the standard XenForo REST API, and a listing's owner can additionally read that listing's votes. Same base path, same authentication, same error shape as the rest of the forum.
Everything on this page uses one key, and it is yours: you generate it on your own listing, no staff involvement, and it works for that listing only. Treat it like a password - anyone holding it can ask whether a given member voted for you.
Copy it somewhere safe. Treat it like a password: anyone holding it can ask whether a given member voted for your listing.
Send it as a header, or as a query parameter if your game server can only manage a URL:
# header (preferred - stays out of access logs)
curl -H 'RZ-Listing-Key: YOUR_LISTING_KEY' \
'https://forum.ragezone.com/topsites/51/vote-check?user_id=12'
# query parameter
curl 'https://forum.ragezone.com/topsites/51/vote-check?key=YOUR_LISTING_KEY&user_id=12'
The endpoint most reward integrations actually want. One player, one answer, using your listing key.
GET https://forum.ragezone.com/topsites/{listing_id}/vote-check
| key | Your listing key, unless you sent the RZ-Listing-Key header. |
|---|---|
| ref | Your own player reference, exactly as you put it on the vote link. The way to ask about a player with no forum account. Checked first when present. |
| user_id | Forum user id. Preferred over username - a member can change their name but not their id. |
| username | Used only when user_id is absent. |
| period | YYYY-MM in UTC. Defaults to the current period. |
{
"content_id": 51,
"user_id": 12,
"period": "2026-08",
"voted": true,
"vote_date": 1787416627,
"vote_source": "site"
}
voted is the answer; the rest is context. A withdrawn vote
returns false.
| 403 | invalid_key - key missing, wrong, or not generated yet. |
|---|---|
| 400 | no_user or bad_period. |
There are two ways to reward a voter, and they are not mutually exclusive.
| Push - the vote callback | We call your server the moment somebody votes. Instant, no polling, no API key. But delivery can fail while your server is down, and you have to run a public HTTPS endpoint and verify signatures. |
|---|---|
| Pull - vote-check | Your server asks us when it needs to know. No public endpoint, no signature handling, nothing to miss during an outage. But it only happens when a player asks, so you need a button or a command. |
The player clicks "claim vote reward" on your website, in your launcher, or via an in-game command. Your server does this:
<?php
const RZ_BASE = 'https://forum.ragezone.com';
const RZ_KEY = 'your-listing-key';
const RZ_ITEM = 51; // your listing id
/**
* Ask whether this forum member has voted for us this period.
*/
function hasVoted(int $forumUserId): bool
{
$url = RZ_BASE . '/topsites/' . RZ_ITEM . '/vote-check?user_id=' . $forumUserId;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['RZ-Listing-Key: ' . RZ_KEY],
CURLOPT_TIMEOUT => 5,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code !== 200) {
// Never fail open. If you cannot confirm the vote, do not pay out.
error_log('rz vote-check failed: HTTP ' . $code);
return false;
}
$data = json_decode($body, true);
return !empty($data['voted']);
}
/**
* Claim, once per period. The unique key does the work - two clicks in the
* same second cannot both win.
*/
function claimVoteReward(PDO $db, int $gameAccountId, int $forumUserId): string
{
$period = gmdate('Y-m'); // must be UTC, to match the vote period
if (!hasVoted($forumUserId)) {
return 'not_voted';
}
$stmt = $db->prepare(
'INSERT IGNORE INTO vote_rewards (forum_user_id, vote_period, rewarded_at)
VALUES (?, ?, ?)'
);
$stmt->execute([$forumUserId, $period, time()]);
if ($stmt->rowCount() === 0) {
return 'already_claimed';
}
$db->prepare('UPDATE accounts SET vote_points = vote_points + 1 WHERE id = ?')
->execute([$gameAccountId]);
return 'granted';
}
The same vote_rewards table serves both integrations, so a player
who was already rewarded by the callback gets already_claimed rather
than a second payout. That shared table is the whole trick.
$period = gmdate('Y-m');
$stmt = $db->prepare('SELECT rewarded_at FROM vote_rewards WHERE forum_user_id = ? AND vote_period = ?');
$stmt->execute([$forumUserId, $period]);
if ($stmt->fetchColumn()) {
$message = 'You have claimed this month. Next reward on the 1st.';
} else {
$message = 'Vote now to claim this month’s reward.';
}
voted is true for a period it stays true unless the voter withdraws. Store it and stop asking.Standard XenForo shape:
{ "errors": [ { "code": "...", "message": "...", "params": [] } ] }
| 401 | Missing or invalid API key. |
|---|---|
| 403 | The key is wrong, or the listing is not yours. |
| 400 | No ref, user_id or username supplied, or a period that is not YYYY-MM. |
| 404 | Unknown listing, or a page past the last one. |
POST /topsites/<id>/api-vote casts a vote. It is the one
endpoint that needs no account and no key, so a player who has never
registered here can still vote for you.
Every vote it accepts fires your vote callback exactly like a vote cast on the site, so whatever you already hand out in game keeps working - you do not need to change anything on your side beyond adding the button.
curl -X POST \
-H 'X-RaGEZONE-TopSites: 1' \
-d 'ref=acct_88214' \
'https://forum.ragezone.com/topsites/51/api-vote'
The voter is the address the request arrives from. There is no parameter that changes that, and there will not be one: an endpoint that accepts a claimed identity is one where an owner can manufacture their own votes, and a ranking that can be forged is worth nothing to the players reading it.
The ref parameter is not that. It is your own reference for the
player, echoed back to you in the callback and answerable through
vote-check - it never decides who is voting, never attributes the vote to a
forum member, and buys the caller nothing but its own echo. Up to 64
characters: letters, digits, dot, dash, underscore.
So a game server that calls this for all its players will see every vote arrive from one address, and all but the first refused. That is the endpoint working. Put the call in your launcher, your in-game browser, or simply link players to the site.
X-RaGEZONE-TopSites: 1 is required. It is not a password. It is
there because this endpoint takes no CSRF token, and without the header any
web page could quietly submit a hidden form and vote from every browser that
loaded it. Browsers will not let a form set a custom header; your launcher
sets it in one line.
A successful vote comes back with next_vote_at, the moment that
address becomes eligible again, and next_vote_in_hours, the same
thing counted in hours. Both point at the turn of the month. Use them rather
than assuming a fixed interval - on the 2nd, the answer is nearly thirty days.
Every attempt is logged, accepted or not.
| HTTP | status | Meaning |
|---|---|---|
| 200 | counted | The vote was recorded |
| 400 | header_required | The header was missing |
| 403 | disabled | Turned off by an administrator |
| 403 | not_allowed | Guests may not vote for this listing |
| 405 | post_required | You used GET |
| 409 | already_voted | Already voted this period |
| 429 | refused | A rate limit stopped it - wait and retry |
refused does not say which limit caught you, on purpose. Telling
a bot what stopped it is telling it what to change.