Vote callback guide
Give players credit in your own game after Conquer Online Private Servers confirms their vote.
What this is: After a confirmed vote, Conquer Online Private Servers sends one server-to-server HTTPS GET request to your callback URL. This basic callback integration is free for every approved listing; Gold membership is not required.
01. Before you start
- Your listing must be approved. Basic callback configuration and delivery are free; Gold membership is not required.
- Open your listing’s edit page and enter a publicly reachable HTTPS callback URL. HTTP callbacks are not accepted.
- Generate or copy the listing’s API key when it is displayed in the owner tools. Store it securely: the full key is shown only after creation or rotation.
- Send players to your vote page with their game account identifier in the
useridquery parameter.
Unverified identifier: C.O.P.S. forwards the supplied
useridand does not verify that the voter controls that game account. Treat it as untrusted; validate it against your own player records before granting a reward.
The callback URL is configured per listing. After approval, add a publicly reachable HTTPS callback URL in the owner form; the basic integration is free and applies to that server.
02. Player vote link
Replace PLAYER_ID with a stable external identifier from your own game. Do not expose a database primary key when a separate public account identifier is available.
https://conqueronlineprivateservers.com/server/YOUR-SLUG/vote?userid=PLAYER_ID
Example PHP link generation:
<?php
$slug = 'tempestco';
$playerId = 'player-12345';
$voteUrl = 'https://conqueronlineprivateservers.com/server/'
. rawurlencode($slug)
. '/vote?userid='
. rawurlencode($playerId);
header('Location: ' . $voteUrl);
exit;
03. Callback request
For a confirmed vote, the directory makes a GET request to your saved HTTPS callback URL. Authentication is sent in the HTTP header, never in the URL:
Authorization: Bearer YOUR_PRIVATE_KEY
| Parameter | Example | Meaning |
|---|---|---|
userid |
player-12345 |
The exact external player identifier supplied in the vote link. |
status |
success |
Sent for a confirmed vote. Receivers must still validate it. |
site_id |
123 |
The numeric Conquer Online Private Servers listing ID. |
vote_id |
456789 |
The stable, immutable identifier of this confirmed directory vote. |
A request is sent only when the vote includes a non-empty userid. Votes without a player identifier still count on the directory but cannot be credited to a game account.
https://your-server.example/vote-callback?userid=player-12345&status=success&site_id=123&vote_id=456789
Create a database uniqueness rule equivalent to UNIQUE(site_id, vote_id). Insert that event record and grant the reward in the same transaction so duplicate delivery can never award twice.
04. Plain PHP receiver
The database functions below are application-specific placeholders. They must look up a player by a safe external identifier and process the event transactionally.
<?php
declare(strict_types=1);
$expectedApiKey = (string) getenv('COPS_API_KEY');
$expectedSiteId = 123;
$authorization = (string) ($_SERVER['HTTP_AUTHORIZATION']
?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
?? '');
$receivedApiKey = preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)
? trim($matches[1])
: '';
$siteId = filter_input(INPUT_GET, 'site_id', FILTER_VALIDATE_INT);
$playerExternalId = trim((string) ($_GET['userid'] ?? ''));
$voteId = trim((string) ($_GET['vote_id'] ?? ''));
$status = (string) ($_GET['status'] ?? '');
if ($expectedApiKey === ''
|| $receivedApiKey === ''
|| !hash_equals($expectedApiKey, $receivedApiKey)
|| $siteId !== $expectedSiteId
|| $status !== 'success'
|| $playerExternalId === ''
|| !ctype_digit($voteId)
|| (int) $voteId < 1) {
http_response_code(403);
exit('Invalid callback');
}
$pdo->beginTransaction();
try {
$player = findPlayerByExternalId($pdo, $playerExternalId);
if ($player === null) {
$pdo->rollBack();
http_response_code(404);
exit('Player not found');
}
// recordVoteRewardIfNew must rely on UNIQUE(site_id, vote_id).
$isNew = recordVoteRewardIfNew($pdo, $expectedSiteId, $voteId, $player['id']);
if ($isNew) {
grantVoteReward($pdo, $player['id']);
}
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
http_response_code(500);
exit('Callback processing failed');
}
http_response_code(204);
Provide your own configured $pdo, findPlayerByExternalId, recordVoteRewardIfNew, and grantVoteReward implementations. Keep COPS_API_KEY in the environment, not in source control.
05. Laravel receiver
Add a route to your game’s Laravel application:
// routes/web.php
use App\Http\Controllers\ConquerVoteCallbackController;
use Illuminate\Support\Facades\Route;
Route::get('/integrations/conquer-vote', [
ConquerVoteCallbackController::class,
'handle',
]);
Then validate and process the callback:
<?php
namespace App\Http\Controllers;
use App\Models\GamePlayer;
use App\Models\VoteReward;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class ConquerVoteCallbackController extends Controller
{
public function handle(Request $request): Response
{
$receivedKey = (string) $request->bearerToken();
$expectedKey = (string) config('services.conquer.api_key');
$playerExternalId = trim((string) $request->query('userid', ''));
$voteId = trim((string) $request->query('vote_id', ''));
abort_unless(
$expectedKey !== ''
&& $receivedKey !== ''
&& hash_equals($expectedKey, $receivedKey)
&& (int) $request->query('site_id') === 123
&& $request->query('status') === 'success'
&& $playerExternalId !== ''
&& ctype_digit($voteId)
&& (int) $voteId > 0,
403
);
$player = GamePlayer::query()
->where('external_id', $playerExternalId)
->firstOrFail();
DB::transaction(function () use ($player, $voteId): void {
// The database must enforce UNIQUE(site_id, vote_id).
$inserted = VoteReward::query()->insertOrIgnore([
'site_id' => 123,
'vote_id' => $voteId,
'player_id' => $player->getKey(),
'created_at' => now(),
'updated_at' => now(),
]);
if ($inserted === 1) {
// Application-specific method: grant the reward in this transaction.
$player->grantVoteReward();
}
});
return response()->noContent();
}
}
GamePlayer, VoteReward, and grantVoteReward() are application-specific placeholders. Configure services.conquer.api_key from an environment variable and add a unique database index on site_id, vote_id.
06. Python / Flask receiver
The database helpers are application-specific placeholders. record_vote_reward_if_new must use a unique site_id, vote_id constraint.
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.get('/integrations/conquer-vote')
def conquer_vote():
expected_key = os.environ.get('COPS_API_KEY', '')
scheme, separator, token = request.headers.get('Authorization', '').partition(' ')
received_key = token.strip() if separator and scheme.lower() == 'bearer' else ''
site_id = request.args.get('site_id', type=int)
player_external_id = request.args.get('userid', '').strip()
vote_id = request.args.get('vote_id', '').strip()
if (
not expected_key
or not received_key
or not hmac.compare_digest(expected_key, received_key)
or site_id != 123
or request.args.get('status') != 'success'
or not player_external_id
or not vote_id.isdecimal()
or int(vote_id) < 1
):
abort(403)
with database.transaction():
player = find_player_by_external_id(player_external_id)
if player is None:
abort(404)
is_new = record_vote_reward_if_new(site_id, vote_id, player.id)
if is_new:
grant_vote_reward(player.id)
return ('', 204)
Provide your own database, player lookup, idempotency insert, and reward functions. Keep the API key in the environment and return quickly after the transaction commits.
07. C# / ASP.NET Core receiver
This minimal API example rejects empty or differently sized secrets before using CryptographicOperations.FixedTimeEquals. Replace the two application-specific functions with your own database implementation.
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/integrations/conquer-vote", async (
HttpRequest request,
IConfiguration config) =>
{
const int expectedSiteId = 123;
var expectedKey = config["COPS_API_KEY"] ?? "";
var authorization = request.Headers.Authorization.ToString();
var receivedKey = authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? authorization["Bearer ".Length..].Trim()
: "";
var expectedBytes = Encoding.UTF8.GetBytes(expectedKey);
var receivedBytes = Encoding.UTF8.GetBytes(receivedKey);
var keysMatch = expectedBytes.Length > 0
&& expectedBytes.Length == receivedBytes.Length
&& CryptographicOperations.FixedTimeEquals(expectedBytes, receivedBytes);
var validSiteId = int.TryParse(
request.Query["site_id"],
NumberStyles.None,
CultureInfo.InvariantCulture,
out var siteId) && siteId == expectedSiteId;
var validVoteId = long.TryParse(
request.Query["vote_id"],
NumberStyles.None,
CultureInfo.InvariantCulture,
out var voteId) && voteId > 0;
var playerExternalId = request.Query["userid"].ToString().Trim();
if (!keysMatch
|| !validSiteId
|| !validVoteId
|| request.Query["status"].ToString() != "success"
|| playerExternalId.Length == 0)
{
return Results.StatusCode(StatusCodes.Status403Forbidden);
}
var player = await FindPlayerByExternalIdAsync(playerExternalId);
if (player is null)
{
return Results.NotFound();
}
// Must atomically enforce UNIQUE(site_id, vote_id) and grant only if new.
await ProcessVoteRewardOnceAsync(siteId, voteId, player.Id);
return Results.NoContent();
});
app.Run();
// Application-specific placeholders:
static Task<ExamplePlayer?> FindPlayerByExternalIdAsync(string externalId) =>
throw new NotImplementedException();
static Task ProcessVoteRewardOnceAsync(int siteId, long voteId, long playerId) =>
throw new NotImplementedException();
public sealed record ExamplePlayer(long Id);
Implement both placeholders using your game database. The idempotency insert and reward update must occur in one transaction, backed by a unique index on site_id, vote_id.
08. Security and reliability
- The callback URL must use HTTPS and resolve only to publicly routable addresses. Redirects are not followed.
- The API key is sent only as
Authorization: Bearer <API_KEY>. Do not put it in a callback URL, query string, log message, or error response. - Compare the key using a constant-time comparison and reject empty credentials.
- Validate the expected
site_id, exactstatus=success, non-empty externaluserid, and positivevote_idbefore looking up a player. - Do not treat
useridas a trusted database primary key. Resolve it through your own external-identifier column. - Enforce
UNIQUE(site_id, vote_id)in the database. Insert the event and grant the reward atomically. - Return a quick 2xx response after successful processing. The directory uses a two-second connection timeout and a three-second total timeout.
- The directory records the legitimate vote before attempting the owner callback. A timeout, rejected URL, redirect, non-2xx response, or receiver failure never rolls back the directory vote.
- The current integration makes one delivery attempt and does not queue or retry failed callbacks.
- The basic callback is not gated by Gold. An approved listing needs a saved safe HTTPS endpoint and API key; delivery occurs only for a confirmed vote with a non-empty userid.
09. Troubleshooting
- No callback arrives: confirm the listing is approved, the vote URL included a non-empty
userid, and the saved HTTPS callback URL and API key are current. Gold membership is not required for the basic callback. - Player is not identified: URL-encode the player identifier when generating the vote link, then look it up through your external-ID field.
- 403 from your receiver: verify the Bearer key, expected numeric
site_id, exactstatus=success, non-emptyuserid, and positivevote_id. - Duplicate rewards: add
UNIQUE(site_id, vote_id)and keep the idempotency insert and reward grant in one transaction. - Redirect response: save the final public HTTPS endpoint directly; the sender does not follow redirects.
List a server or return to the server directory.