You can have us call your game server every time someone votes for your listing, so you can give that player something in game - vote points, a currency, a buff, a crate. This page covers the whole integration.
There is nothing to install. You provide one URL that accepts a POST, and we call it.
Somebody votes for your server on this site. We tell your server they voted. Your server gives them something in game.
That is the whole feature. The rest of this page is how to wire it up, in order, with nothing assumed. If you have never done anything like this before, you are the person this page is written for.
| You need | Why |
|---|---|
| A listing here | You have one already if you are reading this from your control panel. |
| A web address on your own server | A page we can send a message to. It has to be reachable from the internet - localhost will not work, because we are not on your machine. |
| Somewhere to write code | PHP is used in the examples below because most game server sites already run it. Any language works. |
Do this first. It takes two minutes, needs no code, and it turns the rest of this page from theory into something you have already seen happen.
| 1 | Open webhook.site in a new tab. It hands you a unique web address straight away and shows you every message sent to it. Nothing to install, no account, no cost. |
|---|---|
| 2 | Copy the address it shows you at the top. It looks like
https://webhook.site/8f3c1b2e-... |
| 3 | On your listing here, open Manage listing → Rewards. |
| 4 | Paste the address into Callback URL. Type anything you like
into Callback secret - test123 is fine for now.
Press Save. |
| 5 | Press Send test callback. It appears in the Delivery box once a callback URL is saved - if you cannot see it, step 4 has not been saved yet. You do not need a listing key for this. |
| 6 | Switch back to the webhook.site tab. The message is sitting there. |
What you are looking at is exactly what your own server will receive. On the
left is the body - the JSON telling you who voted. On the right are the
headers, including RZ-Signature, which is how you know the
message really came from us.
Copy this into a file on your server. Call it vote.php and put
it somewhere reachable, for example
https://yourserver.com/vote.php. It does nothing useful yet - it
only writes down what arrives - but it proves the connection end to end.
<?php
// Everything that arrives, written to a file next to this one.
$body = file_get_contents('php://input');
file_put_contents(
__DIR__ . '/votes.log',
date('c') . ' ' . $body . "\n",
FILE_APPEND
);
// Always answer 200. If you do not, we treat it as a failure and try again.
http_response_code(200);
echo 'ok';
Put https://yourserver.com/vote.php into Callback URL,
save, and press Send test callback again. Then open
votes.log. If a line appeared, the connection works and
everything after this is just deciding what to do with it.
If nothing appeared, stop here and fix that first - the rest of the page cannot help until a message is arriving. The Troubleshooting table below covers what usually causes it.
Here is a real one, formatted for reading:
{
"content_id": 51,
"title": "Your Server Name",
"data": {
"user_id": 12,
"username": "SomePlayer",
"period": "2026-08",
"votes": 184,
"source": "site",
"ref": "acct_88214",
"time": 1787416627
}
}
| Field | What it is, and what to do with it |
|---|---|
data.user_id |
Their forum account number. 0 means a guest - somebody who voted without an account here. Use this if your players link their forum account to their game account. |
data.username |
Their forum name, or null for a guest. Do not key rewards on this - people change their name. Use user_id. |
data.ref |
Your own player id, if you put one on the vote link. This is how you reward guests. See Step 4. |
data.period |
Which month this vote belongs to, as YYYY-MM. Store it - it is half of how you stop paying twice. |
content_id |
Your listing's number. Only useful if you run several listings through one script. |
data.source |
Where the vote came from: site, badge, api, or test for the test button. Never pay out on test. |
data.votes |
Your listing's total for the month, after this vote. For display, not for rewarding. |
Link players to your listing. Put the link in your launcher, on your website, in a Discord command, or behind an in-game NPC.
Anyone on the internet can POST to your URL. If you skip this step, anyone who finds it can mint themselves unlimited rewards.
RZ-Signature is sha256= followed by the hex
HMAC-SHA256 of the timestamp, a full stop, and the raw request body, keyed on
your secret.
$secret = 'your-secret';
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_RZ_TIMESTAMP'] ?? '';
$received = $_SERVER['HTTP_RZ_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
// Reject anything more than five minutes old, so a captured callback
// cannot be replayed at you later.
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(408);
exit;
}
$payload = json_decode($body, true);
const crypto = require('crypto')
// express.raw is required. express.json() consumes the raw body.
app.post('/api/rz-vote', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('RZ-Timestamp') || ''
const received = req.get('RZ-Signature') || ''
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.RZ_SECRET)
.update(timestamp + '.' + req.body)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(received)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401)
}
const payload = JSON.parse(req.body.toString('utf8'))
res.sendStatus(200)
})
using var reader = new StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
var timestamp = Request.Headers["RZ-Timestamp"].ToString();
var received = Request.Headers["RZ-Signature"].ToString();
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(timestamp + "." + body));
var expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(received)))
{
return Unauthorized();
}
Always compare with a constant-time function - hash_equals,
timingSafeEqual, FixedTimeEquals. A plain equality
check leaks the secret one byte at a time to anyone willing to measure.
Send us your own reference for the player, and we hand it straight back.
Your game already knows who is voting - they clicked the button in your launcher, on your site, or from an NPC, where they are signed in to your server. Our forum does not know that, and does not need to. Put your reference on the vote link and read it back off the callback.
https://forum.ragezone.com/topsites/51/vote?ref=acct_88214
Or, if you are calling the vote endpoint from your own client:
POST /topsites/51/api-vote
X-RaGEZONE-TopSites: 1
ref=acct_88214
It arrives as data.ref. Look it up, grant the reward, done. No
token, no time window, no matching.
Use an account id rather than a display name. A name the player can change is a reference that stops matching the day they change it.
| Not signed in to the forum | ref is yours, user_id
is 0, username is null. Your
reference is all you need. |
|---|---|
| Signed in to the forum | You get both - your reference and their real forum id and username. |
| No reference sent | ref is empty. If they were signed
in you can still match on user_id; if they were not,
there is nobody to reward. Answer 200 and move on - do not error, or
delivery is retried. |
Some setups cannot - a plain banner on a third-party site, for instance. Then you are back to matching on forum identity, which only works for players who are signed in:
data.user_id against a game account. Ids do not
change; usernames do, so prefer the id even if you collect it by asking
the player to paste their profile URL.data.username is the weakest option. Players
typo it, change it, and enter other people's.Delivery is at-least-once. A timeout or a 5xx is retried, so the same vote can legitimately arrive twice. Key your rewards on something stable:
CREATE TABLE vote_rewards (
forum_user_id INT UNSIGNED NOT NULL,
vote_period CHAR(7) NOT NULL,
rewarded_at INT UNSIGNED NOT NULL,
PRIMARY KEY (forum_user_id, vote_period)
) ENGINE=InnoDB;
$stmt = $pdo->prepare(
'INSERT IGNORE INTO vote_rewards (forum_user_id, vote_period, rewarded_at)
VALUES (?, ?, ?)'
);
$stmt->execute([$userId, $period, time()]);
if ($stmt->rowCount() === 0) {
// Already rewarded this voter for this period. Answer 200 anyway.
http_response_code(200);
exit;
}
grantReward($userId);
That converts at-least-once delivery into exactly-once rewarding, which is what you actually want.
| One callback per voter, per listing, per period | A voter who withdraws and re-votes does not generate a second callback. You will never be asked to pay twice for the same voter in the same month. |
|---|---|
| Periods are UTC months | data.period tells you which. Everyone becomes eligible again when it changes. |
| Callbacks are queued | Sent by a background job, so expect a short delay after the vote - usually seconds. Do not build a UI that blocks waiting for it. |
| Delivery is at-least-once | Retried on failure. Deduplicate, as above. |
| 2xx | Success. Done. |
|---|---|
| 4xx or 5xx | Treated as a failure and retried on a backoff. |
| Timeout | Retried on a backoff. The limit is 10 seconds. |
| Redirect | Not followed. Point the URL at its final destination. |
| DNS failure | Logged and abandoned. |
| Resolves to a private address | Refused and logged. Never sent. |
Each vote is attempted four times: once, then again after 5, 10 and 20 minutes. A callback that arrives half an hour late is a retry succeeding, not something going wrong - your receiver has to cope with that, which is what the idempotency advice above is for.
If all four attempts are refused, that vote's reward has been abandoned rather than delayed, and you get an alert. Once per run of failures - not once per vote, so a server down for a weekend does not bury your notifications - and nothing further until a delivery succeeds.
The alert takes you to Manage listing → Rewards, where the exact error sits beside the Send test callback button, so you can fix it and prove it fixed on the same screen. The delivery panel there also shows your last successful delivery, which is the quickest way to answer "is this working right now".
The easiest way: on your listing, open Set up vote callback and press
Send test callback. We send a fully signed callback to your URL with
data.source set to test - verify the signature, log
it, and do not grant a reward for it. The Delivery block on the same page
shows the last successful delivery, or the exact error if your endpoint is
failing.
To test from your own machine instead, sign a request yourself and send it:
BODY='{"content_id":1,"title":"Test","data":{"user_id":1,"username":"you","period":"2026-08","votes":1,"source":"site","ref":"acct_88214","time":0}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "YOUR_SECRET" -r | cut -d' ' -f1)
curl -i -X POST https://yourserver.com/api/rz-vote \
-H 'Content-Type: application/json' \
-H "RZ-Timestamp: $TS" \
-H "RZ-Signature: sha256=$SIG" \
-H 'RZ-Content-Id: 1' \
--data "$BODY"
If that returns 200 and grants a reward, your integration works. Run it twice to confirm the second attempt is deduplicated rather than paying out again.
| Nothing arrives | Check the Delivery block on the Rewards tab; it records the last error we saw. Ask a staff member to check the server error log. Refused callbacks are logged there with the reason. |
|---|---|
| "non-public address" | Your URL points inside a private network. Use your public hostname. |
| "port is not allowed" | Only 80, 443, 8080, 8443. Put a reverse proxy in front of anything else. |
| Signature never matches | You are almost certainly signing a re-encoded body, or forgetting the timestamp and full stop prefix. |
| Worked, then stopped | You were probably reading the legacy secret header. Move to RZ-Signature. |
| Rewards granted twice | You have no dedupe table. See Step 6. |
Everything above is push - we call you when somebody votes. Push is instant, but it can only reach a server that is running. If your game server is down for a patch when a player votes, that callback fails and the player is owed a reward you never heard about.
The fix is a pull endpoint. Your server asks us whether a specific player has voted, and rewards them on the spot:
GET /topsites/51/vote-check?ref=acct_88214
RZ-Listing-Key: your-listing-key
{ "content_id": 51, "ref": "acct_88214", "user_id": 0, "period": "2026-08",
"voted": true, "vote_date": 1787416627, "vote_source": "api" }
The listing key is yours to generate: open your listing, press Manage listing, go to Rewards, and press Generate key. You do not need anything from board staff, and it only works for your own listing.
Ask by ref - the same reference you put on the vote link -
and it works for every player, whether or not they have a forum account.
user_id and username are also accepted for servers
that match on forum identity instead.
Wire that to a "claim vote reward" button in your launcher or an in-game command. Use the same deduplication table as the callback, and a player who was already rewarded automatically simply gets told so.
Full reference, scopes and a complete worked example are on the API page.
RZ-Signature on every request, with a constant-time comparison.data.votes as an authoritative total - it is informational.