← All guides

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

  1. Your listing must be approved. Basic callback configuration and delivery are free; Gold membership is not required.
  2. Open your listing’s edit page and enter a publicly reachable HTTPS callback URL. HTTP callbacks are not accepted.
  3. 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.
  4. Send players to your vote page with their game account identifier in the userid query parameter.

Unverified identifier: C.O.P.S. forwards the supplied userid and 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.

Plain text
https://conqueronlineprivateservers.com/server/YOUR-SLUG/vote?userid=PLAYER_ID

Example PHP link generation:

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

Plain text
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
<?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:

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

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

C#
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, exact status=success, non-empty external userid, and positive vote_id before looking up a player.
  • Do not treat userid as 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, exact status=success, non-empty userid, and positive vote_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.