TopSites: Reward Your Players for Voting

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.

What this actually does

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.

Step 0 - watch one arrive, before you write anything

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.

Before you forget: put your real address back in Callback URL when you are done here, or your votes will keep going to webhook.site instead of your server. Change the secret too - the one you just used has been sent to a public address anybody with the link can read.

Step 1 - the smallest thing that works

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
<?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.

Step 2 - what is in the message

Here is a real one, formatted for reading:

JSON
{
  "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.

Step 3 - put your player id on the vote link

Link players to your listing. Put the link in your launcher, on your website, in a Discord command, or behind an in-game NPC.

Link to your listing page, not directly to the vote URL. The vote URL shows a confirmation and only records a vote on a confirmed submission - a link preview, a crawler, or a browser prefetch cannot cast a vote. Anything promising to "auto-vote" is either broken or trying to cheat.

Step 4 - verify the signature

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.

Sign the raw body bytes. If you parse the JSON and re-encode it, the whitespace and key order change and the signature will never match. This is the single most common integration problem.

PHP

PHP
$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);

Node.js

JavaScript
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)
})

C#

C#
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.

Step 5 - work out who to reward

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.

URL
https://forum.ragezone.com/topsites/51/vote?ref=acct_88214

Or, if you are calling the vote endpoint from your own client:

HTTP
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.

  • Up to 64 characters
  • Letters, digits, dot, dash and underscore - nothing else
  • Anything that does not match is dropped. The vote still counts and the reference arrives empty, so validate what you put on the link.

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.

The reference is yours, not ours. We never read it, and it only ever goes back to the server that sent it. Do not put anything secret in it - it is visible in the URL the player clicks.

What you get for each kind of voter

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.

If you cannot put a reference on the link

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:

  • Store 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.
  • Matching on data.username is the weakest option. Players typo it, change it, and enter other people's.

Step 6 - pay out once, not twice

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:

SQL
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;
PHP
$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.

What you can rely on

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.

How failures are handled

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.

You are told when it stops working

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".

Testing without waiting for a vote

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:

Bash
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.

Troubleshooting

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.

The other half: let players claim it themselves

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:

HTTP
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.

Most servers should run both. The callback covers everyone who is online; the claim button covers everyone who was not, and removes an entire category of support ticket.

Full reference, scopes and a complete worked example are on the API page.

Security checklist

  • Verify RZ-Signature on every request, with a constant-time comparison.
  • Reject timestamps outside a few minutes.
  • Keep the secret out of your repository, your client, and your logs.
  • Never trust data.votes as an authoritative total - it is informational.
  • Rotate the secret if you ever paste it somewhere public. Regenerating it on your listing takes seconds.
  • Reward modestly. If a vote is worth more than a few minutes of play, you have created an incentive to attack you.
Back