Time Until Toplist Reset:
00d 00h 00m 00s
Time Until Toplist Reset:
00d 00h 00m 00s
Last updated

Vote return URL guide

After a counted vote, we can send the player straight to a page on your own website with a confirmation signed with your server's own key, so your server can give them their vote reward automatically.

24hBetween votes
6Values sent
HMACSHA-256 signed

What the return URL does

Without a return URL, a player who votes for your server sees "Vote counted" on our site and is taken to your server's page here five seconds later. With one, we send the player's browser straight to a page you choose on your own website, with six values added to the address that confirm the vote. That page checks them with your server's own verification key and gives the player their reward, such as vote points or items, with nothing for you to do by hand.

  1. Your vote linkThe player clicks your vote button or link.
  2. Our vote pageThey solve the captcha, and our VPN, proxy and bot checks run.
  3. Vote countedA refused vote stops here, and the player sees why.
  4. Your pageWe send the player there with the signed values. It checks them and gives the reward.

Two things to know before you start:

  • The player's browser opens your page. Our server does not call yours. So the page has to open from anywhere on the internet, and your site has to know who the player is: we do not know their account name on your server, so we cannot send it.
  • Only counted votes come back. If a vote is refused, for example because the player already voted in the last 24 hours or uses a VPN, they stay on our vote page and see why, and nothing reaches your page.

Running rAthena or Hercules?

Our free Ragnarok vote panel does all of this for you: player login, the vote link, the check and the reward.

Set it up in four steps

  1. Create the page on your website

    Choose an address such as https://your-server.com/vote-callback. It must open from anywhere on the internet, not only on your own network, and answer an ordinary GET request. Players should be logged in on your site before they vote, so the page knows which account to reward.

  2. Add the check and the reward

    Put one of the code samples in that page and fill in your server ID and your server's verification key (step 3 shows where to find it). The sample checks the signature, makes sure the vote is for your server and recent, and pays each vote once. Then give the reward, following the rules that keep rewards fair.

  3. Copy your key and set the address

    Every server has its own verification key. In your dashboard, open Promo tools, choose the server and find its Vote return card: it shows the key and whether the return URL is set. Copy the key into your page where the sample says YOUR_SERVER_KEY, and keep it to yourself: anyone who has it can make vote links for your server.

    To set the address, open My Listings, press Edit on your server, paste the full address, starting with https://, into Vote Return URL and press Update Server. The same field is on the Add Server form. To switch the feature off, empty the field.

    Your server's key is only shown to its owner, in the dashboard: log in, or register as a server owner.

  4. Vote once to test it

    Vote for your server from your own browser. You should land on your page, and the reward should arrive. Test it lists a few more checks.

What your page receives

After a counted vote, we redirect the player to your return URL with six values added to the end of the address. For the address above it looks like this:

Example
https://your-server.com/vote-callback?server_id=123&vote_id=456789&timestamp=1790000000&ip=203.0.113.7&signature=3f9d9eca66e4cd07fcc04b380a3ec88f58c7b715f94e59425d330790fb52a083&signature_v2=ed160b27debb48b5c883c71b02a5e24c4c211b8f5208001b2a6bf2c26595181e

This example's signature_v2 is made with the example key under Test it. Your links are signed with your server's own key.

ValueExampleWhat it is
server_id123Your server's ID on our site: the number at the end of your vote link, vote?id=123.
vote_id456789Our ID for this vote, unique across the whole site. It is part of signature_v2, so you can use it to pay each vote once.
timestamp1790000000When the vote was counted, in Unix time: seconds since 1 January 1970, UTC.
ip203.0.113.7The voter's IP address as we saw it. It can be IPv6, which arrives encoded (2001%3Adb8%3A%3A7); your language decodes it for you.
signature3f9d9e…a083Legacy HMAC-SHA256 of server_id|timestamp|ip with one key that every server shares. It will be retired: check signature_v2 instead.
signature_v2ed160b…181eCheck this HMAC-SHA256 of v2|server_id|vote_id|timestamp|ip with your server's own verification key, as 64 lowercase hexadecimal characters. If we cannot read your key at that moment, it is left out: refuse such a link.
  • If your return URL already has a query string, such as ?page=vote, the values are added after it with &. A # part stays at the end, after the values.
  • Nothing else is sent: not the player's account name, and nothing in a POST body.

Checking signature today? Switch to signature_v2

We still send signature, so pages that check it keep working for now, but it will be retired. It is made with one key that all servers share and leaves out vote_id, so it cannot show that a link came from us for your server. Check signature_v2 with your own key instead, and then stop reading signature.

Check the vote, then give the reward

To check a vote, join v2, server_id, vote_id, timestamp and ip with | between them, using the values exactly as you received them: v2|123|456789|1790000000|203.0.113.7. Sign that text with HMAC-SHA256 and your server's verification key, used as it appears in your dashboard: the 64 characters as text, not decoded. Compare the result with signature_v2 in a way that takes the same time whatever the difference (hash_equals, timingSafeEqual, MessageDigest.isEqual).

Each sample below does this, checks the server ID and the time, and records the vote so it pays only once. Put your server ID where it says 123, your key where it says YOUR_SERVER_KEY, and your database details where it says YOUR_DATABASE, YOUR_DB_USER and YOUR_DB_PASSWORD (Java takes your own database connection). All three were tested against links built the way our vote page builds them, and use the table under Rules that keep rewards fair.

<?php
// vote-callback.php: the page your Vote Return URL points to.
// Ranked Private Servers sends the player here after a counted vote.

const VOTE_KEY  = 'YOUR_SERVER_KEY'; // your server's key: Dashboard > Promo tools > Vote return
const MY_SERVER = '123';             // your server ID, the number in your vote link

session_start();

function vote_param(string $name): string
{
    return isset($_GET[$name]) && is_string($_GET[$name]) ? $_GET[$name] : '';
}

function vote_refuse(int $status, string $message): void
{
    http_response_code($status);
    exit($message);
}

$server_id = vote_param('server_id');
$vote_id   = vote_param('vote_id');
$timestamp = vote_param('timestamp');
$ip        = vote_param('ip');
$signature = vote_param('signature_v2');

// 1. The signature: HMAC-SHA256 of v2|server_id|vote_id|timestamp|ip with your key
$expected = hash_hmac('sha256', "v2|$server_id|$vote_id|$timestamp|$ip", VOTE_KEY);
if (!hash_equals($expected, $signature)) {
    vote_refuse(403, 'This vote link is not valid.');
}

// 2. It must be for your server
if ($server_id !== MY_SERVER) {
    vote_refuse(403, 'This vote is for another server.');
}

// 3. It must be fresh: at most an hour old, at most 5 minutes ahead of your clock
$age = time() - (int) $timestamp;
if ($age > 3600 || $age < -300) {
    vote_refuse(403, 'This vote link has expired.');
}

// 4. Reward the account that is logged in on your site
$account_id = $_SESSION['account_id'] ?? null; // use your own login's session value
if (!$account_id) {
    vote_refuse(401, 'Please log in, then open this link again.');
}

$db = new PDO('mysql:host=localhost;dbname=YOUR_DATABASE;charset=utf8mb4',
              'YOUR_DB_USER', 'YOUR_DB_PASSWORD', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

// 5. Optional: one reward per account a day
$recent = $db->prepare('SELECT 1 FROM vote_rewards
                         WHERE account_id = ? AND rewarded_at > NOW() - INTERVAL 23 HOUR');
$recent->execute([$account_id]);
if ($recent->fetchColumn()) {
    vote_refuse(409, 'You already had a vote reward today.');
}

// 6. Each vote pays once: vote_id is the table's primary key
try {
    $db->prepare('INSERT INTO vote_rewards (vote_id, account_id, rewarded_at) VALUES (?, ?, NOW())')
       ->execute([$vote_id, $account_id]);
} catch (PDOException $e) {
    if ((int) ($e->errorInfo[1] ?? 0) === 1062) { // duplicate key: this vote was paid before
        vote_refuse(409, 'This vote has already been rewarded.');
    }
    throw $e;
}

// 7. Give the reward to $account_id here, for example vote points.
echo 'Thank you for voting! Your reward has been added.';
// Node.js with Express and mysql2: the route your Vote Return URL points to.
// Ranked Private Servers sends the player here after a counted vote.
// Add it to your own Express app (app = express()), after the session
// middleware your site's login uses: req.session must say who is logged in.
const crypto = require('crypto');
const mysql = require('mysql2/promise');

const VOTE_KEY = 'YOUR_SERVER_KEY'; // your server's key: Dashboard > Promo tools > Vote return
const MY_SERVER = '123';            // your server ID, the number in your vote link
const db = mysql.createPool({
  host: 'localhost', user: 'YOUR_DB_USER', password: 'YOUR_DB_PASSWORD', database: 'YOUR_DATABASE',
});

app.get('/vote-callback', async (req, res) => {
  const param = (name) => (typeof req.query[name] === 'string' ? req.query[name] : '');
  const serverId = param('server_id');
  const voteId = param('vote_id');
  const timestamp = param('timestamp');
  const ip = param('ip');
  const signature = param('signature_v2');

  // 1. The signature: HMAC-SHA256 of v2|server_id|vote_id|timestamp|ip with your key
  const expected = Buffer.from(crypto.createHmac('sha256', VOTE_KEY)
    .update(`v2|${serverId}|${voteId}|${timestamp}|${ip}`)
    .digest('hex'));
  const received = Buffer.from(signature);
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.status(403).send('This vote link is not valid.');
  }

  // 2. It must be for your server
  if (serverId !== MY_SERVER) {
    return res.status(403).send('This vote is for another server.');
  }

  // 3. It must be fresh: at most an hour old, at most 5 minutes ahead of your clock
  const age = Math.floor(Date.now() / 1000) - Number(timestamp);
  if (age > 3600 || age < -300) {
    return res.status(403).send('This vote link has expired.');
  }

  // 4. Reward the account that is logged in on your site
  const accountId = req.session && req.session.accountId; // use your own login's session value
  if (!accountId) {
    return res.status(401).send('Please log in, then open this link again.');
  }

  try {
    // 5. Optional: one reward per account a day
    const [recent] = await db.execute(
      'SELECT 1 FROM vote_rewards WHERE account_id = ? AND rewarded_at > NOW() - INTERVAL 23 HOUR',
      [accountId]);
    if (recent.length > 0) {
      return res.status(409).send('You already had a vote reward today.');
    }
    // 6. Each vote pays once: vote_id is the table's primary key
    await db.execute('INSERT INTO vote_rewards (vote_id, account_id, rewarded_at) VALUES (?, ?, NOW())',
      [voteId, accountId]);
  } catch (err) {
    if (err.code === 'ER_DUP_ENTRY') { // duplicate key: this vote was paid before
      return res.status(409).send('This vote has already been rewarded.');
    }
    console.error(err);
    return res.status(500).send('Something went wrong. Please try again later.');
  }

  // 7. Give the reward to accountId here, for example vote points.
  res.send('Thank you for voting! Your reward has been added.');
});
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLIntegrityConstraintViolationException;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/** Checks the values Ranked Private Servers adds to your Vote Return URL. */
public final class VoteReturn {
    private static final String VOTE_KEY = "YOUR_SERVER_KEY"; // your server's key: Dashboard > Promo tools > Vote return
    private static final String MY_SERVER = "123";            // your server ID, the number in your vote link

    /** Null when the link is genuine, for your server and fresh; otherwise why it is refused. */
    public static String check(String serverId, String voteId, String timestamp, String ip, String signatureV2) {
        if (serverId == null || voteId == null || timestamp == null || ip == null || signatureV2 == null) {
            return "This vote link is not valid.";
        }
        // 1. The signature: HMAC-SHA256 of v2|server_id|vote_id|timestamp|ip with your key
        StringBuilder expected = new StringBuilder();
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(VOTE_KEY.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] raw = mac.doFinal(("v2|" + serverId + "|" + voteId + "|" + timestamp + "|" + ip)
                    .getBytes(StandardCharsets.UTF_8));
            for (byte b : raw) {
                expected.append(String.format("%02x", b));
            }
        } catch (Exception e) {
            return "This vote link is not valid.";
        }
        if (!MessageDigest.isEqual(expected.toString().getBytes(StandardCharsets.UTF_8),
                                   signatureV2.getBytes(StandardCharsets.UTF_8))) {
            return "This vote link is not valid.";
        }
        // 2. It must be for your server
        if (!serverId.equals(MY_SERVER)) {
            return "This vote is for another server.";
        }
        // 3. It must be fresh: at most an hour old, at most 5 minutes ahead of your clock
        long age = System.currentTimeMillis() / 1000L - Long.parseLong(timestamp);
        if (age > 3600 || age < -300) {
            return "This vote link has expired.";
        }
        return null;
    }

    /** Records the reward. Null when you may give it; otherwise why not. */
    public static String claim(Connection db, String voteId, long accountId) throws SQLException {
        // 5. Optional: one reward per account a day
        try (PreparedStatement recent = db.prepareStatement(
                "SELECT 1 FROM vote_rewards WHERE account_id = ? AND rewarded_at > NOW() - INTERVAL 23 HOUR")) {
            recent.setLong(1, accountId);
            try (ResultSet rs = recent.executeQuery()) {
                if (rs.next()) {
                    return "You already had a vote reward today.";
                }
            }
        }
        // 6. Each vote pays once: vote_id is the table's primary key
        try (PreparedStatement insert = db.prepareStatement(
                "INSERT INTO vote_rewards (vote_id, account_id, rewarded_at) VALUES (?, ?, NOW())")) {
            insert.setLong(1, Long.parseLong(voteId));
            insert.setLong(2, accountId);
            insert.executeUpdate();
        } catch (SQLIntegrityConstraintViolationException duplicate) {
            return "This vote has already been rewarded.";
        }
        return null;
    }
}

// In the servlet your Vote Return URL points to (doGet), after
// 4. taking accountId from your own login (send a logged-out player to log in first):
//
//     String problem = VoteReturn.check(request.getParameter("server_id"), request.getParameter("vote_id"),
//             request.getParameter("timestamp"), request.getParameter("ip"), request.getParameter("signature_v2"));
//     if (problem != null) {
//         response.sendError(403, problem);
//         return;
//     }
//     problem = VoteReturn.claim(db, request.getParameter("vote_id"), accountId);
//     if (problem != null) {
//         response.sendError(409, problem);
//         return;
//     }
//     // 7. Give the reward to accountId here, for example vote points.

The signature is not the whole check

Keep to the rules below as well, above all one reward per account a day: they are what keep rewards fair.

Rules that keep rewards fair

  • Accept only fresh links. A player reaches your page seconds after voting, so refuse a link whose timestamp is more than an hour old, or more than five minutes ahead of your server's clock. The samples above already do this.
  • Pay each vote once. Store the vote_id of every vote you reward, and refuse one you have seen before. It is part of signature_v2, so nobody can change it to be paid twice, and a unique key makes the database refuse a second payment even when two requests arrive at the same moment.
  • One reward per account a day. We count one vote per server every 24 hours from the same IP address, browser or device. Paying each account once a day also stops a player who votes from several devices from being paid for each of them. The samples use 23 hours, so a player who votes again right on time is not refused.
  • Reward the logged-in account. Take the account from your own login session, never from a name typed into the address.
  • Keep your key secret. Put it only in code that runs on your server: never in a page's HTML or JavaScript, a screenshot or a public repository. If it gets out, press Generate a new key on the Vote return card. The old key stops working at once, so put the new one in your page straight away.
  • Use the IP address with care. It is the address we saw, and your site may see another one: IPv6 on ours and IPv4 on yours, for example, or a Cloudflare address if your site is behind Cloudflare (read the CF-Connecting-IP header there). Use it for logs and limits rather than refusing a vote because it differs.

The samples keep one row per reward in this table. Create it once in MySQL or MariaDB, which most private servers already run:

MySQL / MariaDB
-- One row for every vote you reward
CREATE TABLE vote_rewards (
  vote_id     INT UNSIGNED NOT NULL PRIMARY KEY,  -- each vote pays once
  account_id  INT UNSIGNED NOT NULL,
  rewarded_at DATETIME     NOT NULL,
  KEY account_time (account_id, rewarded_at)
);

Test it

  1. Check your signature code. Put this example key in place of yours for a moment: 64 zeros, never a real key. Your code must turn each text below into its signature_v2. The first one is the example address, so you can also open that address on your page. It is old, so leave out the one-hour check for this test only, then put your own key and the check back.
    Test vectors
    Example key (64 zeros)
    0000000000000000000000000000000000000000000000000000000000000000
    
    Text to sign
    v2|123|456789|1790000000|203.0.113.7
    signature_v2
    ed160b27debb48b5c883c71b02a5e24c4c211b8f5208001b2a6bf2c26595181e
    
    Text to sign
    v2|123|456789|1790000000|2001:db8::7
    signature_v2
    bdbd9dbd8df7eb649111e123b63c22b4319ebd9612e4c81fde7b1725c884502e
  2. Vote once for real. Vote for your server from your own browser. After the captcha you should land on your page with the six values, and the reward should arrive. You can vote again 24 hours later.
  3. Try to break it. Reload the page: there must be no second reward. Change one digit of vote_id, ip or timestamp in the address, or remove signature_v2: the link must be refused.

Ragnarok Online: the free vote panel

For rAthena and Hercules servers we offer a ready-made vote panel. Players log in with their game account, vote from the panel and get vote points when they come back, and an NPC in the game lets them spend the points on items you choose. The panel checks signature_v2 with your server's own key and pays vote points once per vote.

Download the vote panelZIP, 35 KB, free. It needs your server's key from Dashboard → Promo tools → Vote return. It is no longer attached to our emails, so download it here.

What is in the download

FileWhat it does
callback.phpThe page your return URL points to. It checks signature_v2 with your server's key and ID, refuses links older than an hour or already used, and adds the vote points once per vote, and once per cooldown for each account. A player who is not logged in is asked to log in first and then sent on.
index.php, vote.phpThe login page, and the panel itself: the vote link, the time left until the player can vote again, and their available points, lifetime votes and points earned.
config.phpYour settings (step 3 below).
functions.phpCode the other pages share. There is nothing to change in it.
sql/schema.sqlAdds a vote_logs table and a votepoints column to your login table, and brings the tables of the old panel up to date.
npc/vote_point_trader.txtThe Vote Point Trader NPC, in Prontera, where players trade points for items.
doc/README.md and INSTALLATION.md, a full walkthrough including a web server and HTTPS example.

Install it

  1. Upload the panel

    Put it on a web server with PHP 7.4 or newer and the mysqli extension that can reach your game database, ideally on its own address with HTTPS, such as vote.your-server.com.

  2. Create the tables

    Import sql/schema.sql into the database that holds your login table. It works on MySQL 5.7 and 8 and on MariaDB 10 and newer, only adds what is missing and never changes the vote points your players already have, so it is safe to import again.

  3. Edit config.php

    • Your database details: $db_host, $db_port, $db_user, $db_pass and $db_name.
    • $rps_server_id: your server's ID, the number at the end of your vote link.
    • $secret_key: your server's verification key, from its Vote return card in Promo tools. Until it and your server ID are filled in, the panel adds no vote points.
    • $password_is_md5: true only if your login server stores MD5 passwords; rAthena and Hercules store plain passwords unless MD5 was switched on.
    • If you like: $reward_points (1 per vote) and $cooldown_minutes (1450, which is 24 hours and 10 minutes, per account). $external_vote_url can stay empty: the panel then links to your vote page here.
  4. Set your Vote Return URL

    Point it at the panel's callback.php, for example https://vote.your-server.com/callback.php.

  5. Add the NPC

    Copy npc/vote_point_trader.txt into your custom NPC folder, add it to your NPC list, choose the items and prices in its OnInit section and reload your scripts. Each item ID in .ItemID needs its price, in vote points, at the same position in .ItemCost:

    NPC settings
    OnInit:
        setarray .ItemID[0], 35017, 35018, 35019, 35021, 35022, 35020;
        setarray .ItemCost[0], 2, 2, 2, 1, 1, 5;
        end;

Running the old panel?

It checks the old signature, which will be retired. Download the panel again and follow step 9 of doc/INSTALLATION.md: upload the new files, copy your settings into the new config.php (all but the old $secret_key), fill in $rps_server_id and your server's own key, and import sql/schema.sql again. It updates the old tables and keeps every player's points.

Troubleshooting and questions

Where do I find my server's verification key?

In your dashboard: open Promo tools, choose the server and look for its Vote return card. Every server has its own key, and only its owner sees it. If you have several servers, each page uses the key of the server whose ID it checks.

signature_v2 never matches

Check these, most common first:

  • The wrong key. Use this server's key from its Vote return card, not another server's key and not the old shared key.
  • A new key. After Generate a new key, the old key stops working at once: copy the new one into your page.
  • The wrong text. Sign exactly v2|server_id|vote_id|timestamp|ip: the letters v2 first, then the four values in that order, with | between them and no spaces.
  • A decoded key. Use the key's 64 characters as text. Do not turn them into bytes first.
  • Changed values. Use the values as your language reads them from the address, without trimming or rewriting them: an IPv6 address stays exactly as it arrives.
  • The wrong value. Compare with signature_v2, not signature, in lowercase hexadecimal.

Then check your code with the test vectors under Test it.

Every vote is refused as expired

A link must be at most an hour old and at most five minutes ahead of your server's clock. If every vote fails this check, your server's clock is wrong: turn on time synchronisation (NTP). The timestamp is Unix time, so time zones make no difference.

Do you still send the old signature?

Yes, for now, so pages that check signature keep working. It will be retired, so switch to signature_v2: it is made with your server's own key and covers vote_id as well.

Players are not sent to my page after voting

Only a counted vote sends the player on. Check that your address is saved under Vote Return URL: My Listings, then Edit. If the player saw a message on our vote page instead, the vote was refused (already voted in the last 24 hours, a VPN or proxy, a failed captcha), and refused votes never reach your page.

My page opens, but the values are missing

If your site redirects the address, for example from http:// to https:// or to www., the redirect has to keep the query string; or simply enter the final address. A # part in your return URL is fine: we add the values before it.

The player arrives logged out, so I do not know whom to reward

We do not know your players' account names, so your site has to. Ask players to log in on your site before they vote; the Ragnarok panel sends a logged-out player to its login page and on to the reward afterwards. The samples record nothing for a logged-out player, so the same link still works once they have logged in, for up to an hour. If your login cookie is set with SameSite=Strict, browsers leave it out when a player arrives from another site; SameSite=Lax works.

Someone else has my key

Press Generate a new key on the server's Vote return card. The old key stops working at once, and votes are refused until your page has the new one, so put it in straight away.

Can I use http:// instead of https://?

The form accepts both, but use https://: the address carries the voter's IP address, and HTTPS keeps it private on the way.

How often can a player earn a reward?

Once a day, when your page keeps to the rules above. We allow one counted vote per server every 24 hours from the same IP address, browser or device, and one reward per account a day covers a player who votes from several devices. How rankings work explains the checks.

May I reward reviews too?

No. Rewarding votes is allowed; rewarding reviews or ratings is not. See the rules for server owners.

Where can I get help?

Send us a message through the contact form (you need to be logged in). Tell us your server's name and the return URL you set, but never your key.

List your server for free

Register as a server owner and add your server, with its return URL on the same form. Its key is then waiting in Promo tools.